From 81331ec4d1ac438ae32b339398344e46dc0535db Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 10 Apr 2025 10:50:23 -0700 Subject: [PATCH] Update db schema for ssh login mappings --- backend/src/@types/knex.d.ts | 22 +- .../migrations/20250405185753_ssh-mgmt-v2.ts | 32 +- backend/src/db/schemas/index.ts | 3 +- backend/src/db/schemas/models.ts | 3 +- .../src/db/schemas/ssh-host-login-mappings.ts | 21 -- .../schemas/ssh-host-login-user-mappings.ts | 22 ++ .../src/db/schemas/ssh-host-login-users.ts | 20 ++ backend/src/ee/routes/v1/ssh-host-router.ts | 58 +--- .../ee/services/audit-log/audit-log-types.ts | 8 +- .../src/ee/services/ssh-host/ssh-host-dal.ts | 89 +++--- .../ssh-host/ssh-host-login-mapping-dal.ts | 10 - .../ssh-host-login-user-mapping-dal.ts | 10 + .../ee/services/ssh-host/ssh-host-schema.ts | 9 + .../ee/services/ssh-host/ssh-host-service.ts | 147 +++++++--- .../ee/services/ssh-host/ssh-host-types.ts | 8 +- .../services/ssh-host/ssh-login-user-dal.ts | 10 + backend/src/server/routes/index.ts | 9 +- .../src/server/routes/v2/project-router.ts | 9 +- frontend/src/hooks/api/sshHost/types.ts | 12 +- .../SshHostsPage/components/SshHostModal.tsx | 274 +++++++++++++----- .../SshHostsPage/components/SshHostsTable.tsx | 6 +- 21 files changed, 516 insertions(+), 266 deletions(-) delete mode 100644 backend/src/db/schemas/ssh-host-login-mappings.ts create mode 100644 backend/src/db/schemas/ssh-host-login-user-mappings.ts create mode 100644 backend/src/db/schemas/ssh-host-login-users.ts delete mode 100644 backend/src/ee/services/ssh-host/ssh-host-login-mapping-dal.ts create mode 100644 backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts create mode 100644 backend/src/ee/services/ssh-host/ssh-login-user-dal.ts diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 277f6f074..5412b546c 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -359,9 +359,12 @@ import { TSshCertificateTemplates, TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate, - TSshHostLoginMappings, - TSshHostLoginMappingsInsert, - TSshHostLoginMappingsUpdate, + TSshHostLoginUserMappings, + TSshHostLoginUserMappingsInsert, + TSshHostLoginUserMappingsUpdate, + TSshHostLoginUsers, + TSshHostLoginUsersInsert, + TSshHostLoginUsersUpdate, TSshHosts, TSshHostsInsert, TSshHostsUpdate, @@ -454,10 +457,15 @@ declare module "knex/types/tables" { TSshCertificateBodiesInsert, TSshCertificateBodiesUpdate >; - [TableName.SshHostLoginMapping]: KnexOriginal.CompositeTableType< - TSshHostLoginMappings, - TSshHostLoginMappingsInsert, - TSshHostLoginMappingsUpdate + [TableName.SshHostLoginUser]: KnexOriginal.CompositeTableType< + TSshHostLoginUsers, + TSshHostLoginUsersInsert, + TSshHostLoginUsersUpdate + >; + [TableName.SshHostLoginUserMapping]: KnexOriginal.CompositeTableType< + TSshHostLoginUserMappings, + TSshHostLoginUserMappingsInsert, + TSshHostLoginUserMappingsUpdate >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, diff --git a/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts b/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts index 18d110498..3f7b4e2bf 100644 --- a/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts +++ b/backend/src/db/migrations/20250405185753_ssh-mgmt-v2.ts @@ -22,16 +22,27 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.SshHost); } - if (!(await knex.schema.hasTable(TableName.SshHostLoginMapping))) { - await knex.schema.createTable(TableName.SshHostLoginMapping, (t) => { + if (!(await knex.schema.hasTable(TableName.SshHostLoginUser))) { + await knex.schema.createTable(TableName.SshHostLoginUser, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); t.uuid("sshHostId").notNullable(); t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("CASCADE"); - t.string("loginUser").notNullable(); - t.specificType("allowedPrincipals", "text[]").notNullable(); + t.string("loginUser").notNullable(); // e.g. ubuntu, root, ec2-user, ... }); - await createOnUpdateTrigger(knex, TableName.SshHostLoginMapping); + await createOnUpdateTrigger(knex, TableName.SshHostLoginUser); + } + + if (!(await knex.schema.hasTable(TableName.SshHostLoginUserMapping))) { + await knex.schema.createTable(TableName.SshHostLoginUserMapping, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostLoginUserId").notNullable(); + t.foreign("sshHostLoginUserId").references("id").inTable(TableName.SshHostLoginUser).onDelete("CASCADE"); + t.uuid("userId").nullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + }); + await createOnUpdateTrigger(knex, TableName.SshHostLoginUserMapping); } if (!(await knex.schema.hasTable(TableName.ProjectSshConfig))) { @@ -62,11 +73,11 @@ export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.ProjectSshConfig); await dropOnUpdateTrigger(knex, TableName.ProjectSshConfig); - await knex.schema.dropTableIfExists(TableName.SshHostLoginMapping); - await dropOnUpdateTrigger(knex, TableName.SshHostLoginMapping); + await knex.schema.dropTableIfExists(TableName.SshHostLoginUserMapping); + await dropOnUpdateTrigger(knex, TableName.SshHostLoginUserMapping); - await knex.schema.dropTableIfExists(TableName.SshHost); - await dropOnUpdateTrigger(knex, TableName.SshHost); + await knex.schema.dropTableIfExists(TableName.SshHostLoginUser); + await dropOnUpdateTrigger(knex, TableName.SshHostLoginUser); const hasColumn = await knex.schema.hasColumn(TableName.SshCertificate, "sshHostId"); if (hasColumn) { @@ -74,4 +85,7 @@ export async function down(knex: Knex): Promise { t.dropColumn("sshHostId"); }); } + + await knex.schema.dropTableIfExists(TableName.SshHost); + await dropOnUpdateTrigger(knex, TableName.SshHost); } diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index f2bd2dd93..a3f215664 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -121,7 +121,8 @@ export * from "./ssh-certificate-authority-secrets"; export * from "./ssh-certificate-bodies"; export * from "./ssh-certificate-templates"; export * from "./ssh-certificates"; -export * from "./ssh-host-login-mappings"; +export * from "./ssh-host-login-user-mappings"; +export * from "./ssh-host-login-users"; export * from "./ssh-hosts"; export * from "./super-admin"; export * from "./totp-configs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 6b56a60ae..6477bf313 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -3,7 +3,8 @@ import { z } from "zod"; export enum TableName { Users = "users", SshHost = "ssh_hosts", - SshHostLoginMapping = "ssh_host_login_mappings", + SshHostLoginUser = "ssh_host_login_users", + SshHostLoginUserMapping = "ssh_host_login_user_mappings", SshCertificateAuthority = "ssh_certificate_authorities", SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", SshCertificateTemplate = "ssh_certificate_templates", diff --git a/backend/src/db/schemas/ssh-host-login-mappings.ts b/backend/src/db/schemas/ssh-host-login-mappings.ts deleted file mode 100644 index 0353589d2..000000000 --- a/backend/src/db/schemas/ssh-host-login-mappings.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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 SshHostLoginMappingsSchema = z.object({ - id: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), - sshHostId: z.string().uuid(), - loginUser: z.string(), - allowedPrincipals: z.string().array() -}); - -export type TSshHostLoginMappings = z.infer; -export type TSshHostLoginMappingsInsert = Omit, TImmutableDBKeys>; -export type TSshHostLoginMappingsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-host-login-user-mappings.ts b/backend/src/db/schemas/ssh-host-login-user-mappings.ts new file mode 100644 index 000000000..6edb0d5a3 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-login-user-mappings.ts @@ -0,0 +1,22 @@ +// 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 SshHostLoginUserMappingsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostLoginUserId: z.string().uuid(), + userId: z.string().uuid().nullable().optional() +}); + +export type TSshHostLoginUserMappings = z.infer; +export type TSshHostLoginUserMappingsInsert = Omit, TImmutableDBKeys>; +export type TSshHostLoginUserMappingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts new file mode 100644 index 000000000..62454d3c9 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-login-users.ts @@ -0,0 +1,20 @@ +// 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 SshHostLoginUsersSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostId: z.string().uuid(), + loginUser: z.string() +}); + +export type TSshHostLoginUsers = z.infer; +export type TSshHostLoginUsersInsert = Omit, TImmutableDBKeys>; +export type TSshHostLoginUsersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 3ad5acfb6..5c17765aa 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; -import { sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; import { SSH_HOSTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; @@ -23,17 +23,12 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { response: { 200: z.array( sanitizedSshHost.extend({ - loginMappings: z.array( - z.object({ - loginUser: z.string(), - allowedPrincipals: z.array(z.string()) - }) - ) + loginMappings: z.array(loginMappingSchema) }) ) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const hosts = await server.services.sshHost.listSshHosts({ actor: req.permission.type, @@ -41,7 +36,6 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); - // TODO: consider adding audit log return hosts; } @@ -59,12 +53,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array( - z.object({ - loginUser: z.string(), - allowedPrincipals: z.array(z.string()) - }) - ) + loginMappings: z.array(loginMappingSchema) }) } }, @@ -121,25 +110,13 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { .refine((val) => ms(val) > 0, "TTL must be a positive number") .default("1y") .describe(SSH_HOSTS.CREATE.hostCertTtl), - loginMappings: z - .object({ - loginUser: z.string().trim().describe(SSH_HOSTS.CREATE.loginUser), // TODO: reinforce validation - allowedPrincipals: z.array(z.string().trim()).describe(SSH_HOSTS.CREATE.allowedPrincipals) // TODO: reinforce validation - }) - .array() - .default([]) - .describe(SSH_HOSTS.CREATE.loginMappings), + loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOSTS.CREATE.loginMappings), userSshCaId: z.string().describe(SSH_HOSTS.CREATE.userSshCaId).optional(), hostSshCaId: z.string().describe(SSH_HOSTS.CREATE.hostSshCaId).optional() }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array( - z.object({ - loginUser: z.string(), - allowedPrincipals: z.array(z.string()) - }) - ) + loginMappings: z.array(loginMappingSchema) }) } }, @@ -205,23 +182,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { .refine((val) => ms(val) > 0, "TTL must be a positive number") .optional() .describe(SSH_HOSTS.UPDATE.hostCertTtl), - loginMappings: z - .object({ - loginUser: z.string().trim().describe(SSH_HOSTS.CREATE.loginUser), - allowedPrincipals: z.array(z.string().trim()).describe(SSH_HOSTS.CREATE.allowedPrincipals) - }) - .array() - .optional() - .describe(SSH_HOSTS.CREATE.loginMappings) + loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOSTS.CREATE.loginMappings) }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array( - z.object({ - loginUser: z.string(), - allowedPrincipals: z.array(z.string()) - }) - ) + loginMappings: z.array(loginMappingSchema) }) } }, @@ -268,12 +233,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array( - z.object({ - loginUser: z.string(), - allowedPrincipals: z.array(z.string()) - }) - ) + loginMappings: z.array(loginMappingSchema) }) } }, 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 155759b8f..884c9b76d 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1470,7 +1470,9 @@ interface CreateSshHost { hostCertTtl: string; loginMappings: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; userSshCaId: string; hostSshCaId: string; @@ -1486,7 +1488,9 @@ interface UpdateSshHost { hostCertTtl?: string; loginMappings?: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; userSshCaId?: string; hostSshCaId?: string; diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts index 52505f18b..7232a4f07 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -11,52 +11,49 @@ export type TSshHostDALFactory = ReturnType; export const sshHostDALFactory = (db: TDbClient) => { const sshHostOrm = ormify(db, TableName.SshHost); - const findSshHostsWithPrincipalsAcrossProjects = async (projectIds: string[], principals: string[], tx?: Knex) => { + const findSshHostsWithPrincipalsAcrossProjects = async (projectIds: string[], userId: string, tx?: Knex) => { try { - const matchingSshHosts = await (tx || db.replicaNode())(TableName.SshHost) + const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first(); + + if (!user) { + throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") }); + } + + const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) .leftJoin( - TableName.SshHostLoginMapping, - `${TableName.SshHost}.id`, - `${TableName.SshHostLoginMapping}.sshHostId` + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .whereIn(`${TableName.SshHost}.projectId`, projectIds) - .whereRaw(`"${TableName.SshHostLoginMapping}"."allowedPrincipals" && ?::text[]`, [principals]) + .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("projectId").withSchema(TableName.SshHost), db.ref("hostname").withSchema(TableName.SshHost), db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost), - db.ref("loginUser").withSchema(TableName.SshHostLoginMapping), - db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) ) .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); - const grouped = groupBy(matchingSshHosts, (r) => r.sshHostId); + const grouped = groupBy(rows, (r) => r.sshHostId); return Object.values(grouped).map((hostRows) => { const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; - const loginMappingGrouped = groupBy( - hostRows.filter((r) => r.loginUser), - (r) => r.loginUser - ); + const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); - const loginMappings = Object.entries(loginMappingGrouped) - .map(([loginUser, entries]) => { - const filteredPrincipals = unique(entries.flatMap((entry) => entry.allowedPrincipals ?? [])).filter( - (principal) => principals.includes(principal) - ); - - if (filteredPrincipals.length === 0) return null; - - return { - loginUser, - allowedPrincipals: filteredPrincipals - }; - }) - .filter(Boolean) as { loginUser: string; allowedPrincipals: string[] }[]; + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ + loginUser, + allowedPrincipals: { + usernames: [user.username] + } + })); return { id: sshHostId, @@ -77,11 +74,13 @@ export const sshHostDALFactory = (db: TDbClient) => { const findSshHostsWithLoginMappings = async (projectId: string, tx?: Knex) => { try { const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) .leftJoin( - TableName.SshHostLoginMapping, - `${TableName.SshHost}.id`, - `${TableName.SshHostLoginMapping}.sshHostId` + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) .where(`${TableName.SshHost}.projectId`, projectId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), @@ -89,8 +88,9 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("hostname").withSchema(TableName.SshHost), db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost), - db.ref("loginUser").withSchema(TableName.SshHostLoginMapping), - db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) ) @@ -107,13 +107,15 @@ export const sshHostDALFactory = (db: TDbClient) => { const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, - allowedPrincipals: unique(entries.flatMap((entry) => entry.allowedPrincipals ?? [])) + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } })); return { id: sshHostId, - projectId, hostname, + projectId, userCertTtl, hostCertTtl, loginMappings, @@ -129,11 +131,13 @@ export const sshHostDALFactory = (db: TDbClient) => { const findSshHostByIdWithLoginMappings = async (sshHostId: string, tx?: Knex) => { try { const rows = await (tx || db.replicaNode())(TableName.SshHost) + .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) .leftJoin( - TableName.SshHostLoginMapping, - `${TableName.SshHost}.id`, - `${TableName.SshHostLoginMapping}.sshHostId` + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) .where(`${TableName.SshHost}.id`, sshHostId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), @@ -141,8 +145,9 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("hostname").withSchema(TableName.SshHost), db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost), - db.ref("loginUser").withSchema(TableName.SshHostLoginMapping), - db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) ); @@ -158,7 +163,9 @@ export const sshHostDALFactory = (db: TDbClient) => { const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, - allowedPrincipals: unique(entries.flatMap((entry) => entry.allowedPrincipals ?? [])) + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } })); return { @@ -178,8 +185,8 @@ export const sshHostDALFactory = (db: TDbClient) => { return { ...sshHostOrm, - findSshHostsWithPrincipalsAcrossProjects, findSshHostsWithLoginMappings, + findSshHostsWithPrincipalsAcrossProjects, findSshHostByIdWithLoginMappings }; }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-login-mapping-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-login-mapping-dal.ts deleted file mode 100644 index 4fdd2c289..000000000 --- a/backend/src/ee/services/ssh-host/ssh-host-login-mapping-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TSshHostLoginMappingDALFactory = ReturnType; - -export const sshHostLoginMappingDALFactory = (db: TDbClient) => { - const sshHostLoginMappingOrm = ormify(db, TableName.SshHostLoginMapping); - return sshHostLoginMappingOrm; -}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts new file mode 100644 index 000000000..0d9e8013b --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-login-user-mapping-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostLoginUserMappingDALFactory = ReturnType; + +export const sshHostLoginUserMappingDALFactory = (db: TDbClient) => { + const sshHostLoginUserMappingOrm = ormify(db, TableName.SshHostLoginUserMapping); + return sshHostLoginUserMappingOrm; +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-schema.ts b/backend/src/ee/services/ssh-host/ssh-host-schema.ts index e76d8e92c..75f7a590c 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-schema.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-schema.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { SshHostsSchema } from "@app/db/schemas"; export const sanitizedSshHost = SshHostsSchema.pick({ @@ -9,3 +11,10 @@ export const sanitizedSshHost = SshHostsSchema.pick({ userSshCaId: true, hostSshCaId: true }); + +export const loginMappingSchema = z.object({ + loginUser: z.string(), + allowedPrincipals: z.object({ + usernames: z.array(z.string()) + }) +}); diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index e462e2282..92ec07f9c 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -9,8 +9,10 @@ import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; -import { TSshHostLoginMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-mapping-dal"; +import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -34,8 +36,8 @@ import { TUpdateSshHostDTO } from "./ssh-host-types"; -type TSshCertificateAuthorityServiceFactoryDep = { - userDAL: Pick; +type TSshHostServiceFactoryDep = { + userDAL: Pick; projectDAL: Pick; projectSshConfigDAL: Pick; sshCertificateAuthorityDAL: Pick; @@ -53,11 +55,9 @@ type TSshCertificateAuthorityServiceFactoryDep = { | "findSshHostByIdWithLoginMappings" | "findSshHostsWithPrincipalsAcrossProjects" >; - sshHostLoginMappingDAL: Pick< - TSshHostLoginMappingDALFactory, - "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "insertMany" | "delete" - >; - permissionService: Pick; + sshHostLoginUserDAL: TSshHostLoginUserDALFactory; + sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; + permissionService: Pick; kmsService: Pick; }; @@ -72,26 +72,26 @@ export const sshHostServiceFactory = ({ sshCertificateDAL, sshCertificateBodyDAL, sshHostDAL, - sshHostLoginMappingDAL, + sshHostLoginUserMappingDAL, + sshHostLoginUserDAL, permissionService, kmsService -}: TSshCertificateAuthorityServiceFactoryDep) => { +}: TSshHostServiceFactoryDep) => { /** * Return list of all SSH hosts that a user can issue user SSH certificates for * (i.e. is able to access / connect to) across all SSH projects in the organization */ const listSshHosts = async ({ actorId, actorAuthMethod, actor, actorOrgId }: TListSshHostsDTO) => { + if (actor !== ActorType.USER) { + // (dangtony98): only support user for now + throw new BadRequestError({ message: `Actor type ${actor} not supported` }); + } + const sshProjects = await projectDAL.find({ orgId: actorOrgId, type: ProjectType.SSH }); - const principals = await convertActorToPrincipals({ - actor, - actorId, - userDAL - }); - const allowedHosts = []; for await (const project of sshProjects) { @@ -105,7 +105,7 @@ export const sshHostServiceFactory = ({ actionProjectType: ActionProjectType.SSH }); - const projectHosts = await sshHostDAL.findSshHostsWithPrincipalsAcrossProjects([project.id], principals); + const projectHosts = await sshHostDAL.findSshHostsWithPrincipalsAcrossProjects([project.id], actorId); // TODO: consider fn rename allowedHosts.push(...projectHosts); } catch { @@ -208,15 +208,51 @@ export const sshHostServiceFactory = ({ tx ); - await sshHostLoginMappingDAL.insertMany( - loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + await sshHostLoginUserDAL.insertMany( + loginMappings.map(({ loginUser }) => ({ sshHostId: host.id, - loginUser, - allowedPrincipals + loginUser })), tx ); + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + { + sshHostId: host.id, + loginUser + }, + tx + ); + + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + for await (const user of users) { + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } + const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); if (!newSshHostWithLoginMappings) { throw new NotFoundError({ message: `SSH host with ID '${host.id}' not found` }); @@ -270,16 +306,58 @@ export const sshHostServiceFactory = ({ ); if (loginMappings) { - await sshHostLoginMappingDAL.delete({ sshHostId }, tx); + await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx); if (loginMappings.length) { - await sshHostLoginMappingDAL.insertMany( - loginMappings.map(({ loginUser, allowedPrincipals }) => ({ - sshHostId: host.id, - loginUser, - allowedPrincipals - })), - tx - ); + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + { + sshHostId: host.id, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length === 0) { + continue; // or maybe insert no mappings and just skip validation + } + + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + const foundUsernames = new Set(users.map((u) => u.username)); + + for (const uname of allowedPrincipals.usernames) { + if (!foundUsernames.has(uname)) { + throw new BadRequestError({ + message: `Invalid username: ${uname}` + }); + } + } + + for await (const user of users) { + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId: host.projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } } } @@ -314,10 +392,7 @@ export const sshHostServiceFactory = ({ }) ); - await sshHostDAL.transaction(async (tx) => { - await sshHostLoginMappingDAL.delete({ sshHostId }, tx); - await sshHostDAL.deleteById(sshHostId, tx); - }); + await sshHostDAL.deleteById(sshHostId); return host; }; @@ -379,7 +454,9 @@ export const sshHostServiceFactory = ({ }); const mapping = host.loginMappings.find( - (m) => m.loginUser === loginUser && m.allowedPrincipals.some((allowed) => internalPrincipals.includes(allowed)) + (m) => + m.loginUser === loginUser && + m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) ); if (!mapping) { diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts index ecd20d347..0c7cb25e1 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -8,7 +8,9 @@ export type TCreateSshHostDTO = { hostCertTtl: string; loginMappings: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; userSshCaId?: string; hostSshCaId?: string; @@ -21,7 +23,9 @@ export type TUpdateSshHostDTO = { hostCertTtl?: string; loginMappings?: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; } & Omit; diff --git a/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts b/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts new file mode 100644 index 000000000..88a9bf59a --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-login-user-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostLoginUserDALFactory = ReturnType; + +export const sshHostLoginUserDALFactory = (db: TDbClient) => { + const sshHostLoginUserOrm = ormify(db, TableName.SshHostLoginUser); + return sshHostLoginUserOrm; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 09b0a512c..b135358bf 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -94,8 +94,9 @@ import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-c import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; -import { sshHostLoginMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-mapping-dal"; +import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; +import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -388,7 +389,8 @@ export const registerRoutes = async ( const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); const sshHostDAL = sshHostDALFactory(db); - const sshHostLoginMappingDAL = sshHostLoginMappingDALFactory(db); + const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db); + const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); @@ -806,7 +808,8 @@ export const registerRoutes = async ( sshCertificateDAL, sshCertificateBodyDAL, sshHostDAL, - sshHostLoginMappingDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, permissionService, kmsService }); diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 2c6030d70..6e4a8170e 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -13,7 +13,7 @@ import { InfisicalProjectTemplate } from "@app/ee/services/project-template/proj import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; -import { sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -616,12 +616,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ hosts: z.array( sanitizedSshHost.extend({ - loginMappings: z.array( - z.object({ - loginUser: z.string(), - allowedPrincipals: z.array(z.string()) - }) - ) + loginMappings: z.array(loginMappingSchema) }) ) }) diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index 02202233f..4bb61008c 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -6,7 +6,9 @@ export type TSshHost = { hostCertTtl: string; loginMappings: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; }; @@ -17,7 +19,9 @@ export type TCreateSshHostDTO = { hostCertTtl?: string; loginMappings: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; }; @@ -28,7 +32,9 @@ export type TUpdateSshHostDTO = { hostCertTtl?: string; loginMappings?: { loginUser: string; - allowedPrincipals: string[]; + allowedPrincipals: { + usernames: string[]; + }; }[]; }; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index 3ca040881..40f470c7b 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -1,9 +1,8 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate } from "@tanstack/react-router"; import ms from "ms"; import { z } from "zod"; @@ -15,10 +14,17 @@ import { IconButton, Input, Modal, - ModalContent + ModalContent, + Select, + SelectItem } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { useCreateSshHost, useGetSshHostById, useUpdateSshHost } from "@app/hooks/api"; +import { + useCreateSshHost, + useGetSshHostById, + useGetWorkspaceUsers, + useUpdateSshHost +} from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -40,7 +46,7 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.string().trim().min(1) + allowedPrincipals: z.array(z.string().trim()).default([]) }) .array() .default([]) @@ -50,9 +56,11 @@ const schema = z export type FormData = z.infer; export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { - const navigate = useNavigate(); const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace?.id || ""; + const { data: members = [] } = useGetWorkspaceUsers(projectId); + const [expandedMappings, setExpandedMappings] = useState>({}); + const { data: sshHost } = useGetSshHostById( (popUp?.sshHost?.data as { sshHostId: string })?.sshHostId || "" ); @@ -64,6 +72,8 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { control, handleSubmit, reset, + getValues, + setValue, formState: { isSubmitting } } = useForm({ resolver: zodResolver(schema), @@ -86,9 +96,13 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { userCertTtl: sshHost.userCertTtl, loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.join(",") + allowedPrincipals: allowedPrincipals.usernames })) }); + + setExpandedMappings( + Object.fromEntries(sshHost.loginMappings.map((_, index) => [index, false])) + ); } else { reset({ hostname: "", @@ -109,7 +123,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { userCertTtl, loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.split(",") + allowedPrincipals: { + usernames: allowedPrincipals + } })) }); } else { @@ -119,17 +135,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { userCertTtl, loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.split(",") + allowedPrincipals: { + usernames: allowedPrincipals + } })) }); - - // navigate({ - // to: `/${ProjectType.SSH}/$projectId/ca/$caId` as const, - // params: { - // projectId, - // caId: newCaId - // } - // }); } reset(); @@ -148,6 +158,13 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { } }; + const toggleMapping = (index: number) => { + setExpandedMappings((prev) => ({ + ...prev, + [index]: !prev[index] + })); + }; + return ( { )} /> -
+
-
-
- {loginMappingsFormFields.fields.map(({ id: metadataFieldId }, i) => ( -
-
- {i === 0 && Login User} - ( - - - - )} - /> -
-
- {i === 0 && ( - - )} - ( - - - - )} - /> -
- loginMappingsFormFields.remove(i)} - > - - -
- ))} -
-
+
+ {loginMappingsFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ + loginMappingsFormFields.remove(i)} + > + + +
+ + {expandedMappings[i] && ( + <> +
+ Login User + ( + + { + const newValue = e.target.value; + const loginMappings = getValues("loginMappings"); + const isDuplicate = loginMappings.some( + (mapping, index) => index !== i && mapping.loginUser === newValue + ); + + if (isDuplicate) { + createNotification({ + text: "This login user already exists", + type: "error" + }); + return; + } + + field.onChange(e); + }} + /> + + )} + /> +
+
+
+ + +
+ ( +
+ {(value.length === 0 ? [""] : value).map( + (principal: string, principalIndex: number) => ( +
+
+ +
+ { + const newPrincipals = value.filter( + (_, idx) => idx !== principalIndex + ); + onChange(newPrincipals); + }} + > + + +
+ ) + )} + {error && {error.message}} +
+ )} + /> +
+ + )} +
+ ))} +