diff --git a/.infisicalignore b/.infisicalignore index 2c52b2b4f..b00bf0995 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -24,5 +24,7 @@ frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26 docs/documentation/platform/kms/overview.mdx:generic-api-key:281 docs/documentation/platform/kms/overview.mdx:generic-api-key:344 +frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:85 docs/cli/commands/user.mdx:generic-api-key:51 -frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:76 \ No newline at end of file +frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:76 +docs/integrations/app-connections/hashicorp-vault.mdx:generic-api-key:188 diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 77db3e59e..6ec542c6b 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -41,6 +41,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; +import { TSshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -214,6 +215,7 @@ declare module "fastify" { sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; sshHost: TSshHostServiceFactory; + sshHostGroup: TSshHostGroupServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7ac57afb2..13f3bc306 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -386,6 +386,12 @@ import { TSshCertificateTemplates, TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate, + TSshHostGroupMemberships, + TSshHostGroupMembershipsInsert, + TSshHostGroupMembershipsUpdate, + TSshHostGroups, + TSshHostGroupsInsert, + TSshHostGroupsUpdate, TSshHostLoginUserMappings, TSshHostLoginUserMappingsInsert, TSshHostLoginUserMappingsUpdate, @@ -455,6 +461,16 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshHostGroup]: KnexOriginal.CompositeTableType< + TSshHostGroups, + TSshHostGroupsInsert, + TSshHostGroupsUpdate + >; + [TableName.SshHostGroupMembership]: KnexOriginal.CompositeTableType< + TSshHostGroupMemberships, + TSshHostGroupMembershipsInsert, + TSshHostGroupMembershipsUpdate + >; [TableName.SshHost]: KnexOriginal.CompositeTableType; [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< TSshCertificateAuthorities, diff --git a/backend/src/db/migrations/20250428173025_ssh-host-groups.ts b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts new file mode 100644 index 000000000..6bac07ae6 --- /dev/null +++ b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts @@ -0,0 +1,55 @@ +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.SshHostGroup))) { + await knex.schema.createTable(TableName.SshHostGroup, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("name").notNullable(); + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostGroup); + } + + if (!(await knex.schema.hasTable(TableName.SshHostGroupMembership))) { + await knex.schema.createTable(TableName.SshHostGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostGroupId").notNullable(); + t.foreign("sshHostGroupId").references("id").inTable(TableName.SshHostGroup).onDelete("CASCADE"); + t.uuid("sshHostId").notNullable(); + t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("CASCADE"); + t.unique(["sshHostGroupId", "sshHostId"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostGroupMembership); + } + + const hasGroupColumn = await knex.schema.hasColumn(TableName.SshHostLoginUser, "sshHostGroupId"); + if (!hasGroupColumn) { + await knex.schema.alterTable(TableName.SshHostLoginUser, (t) => { + t.uuid("sshHostGroupId").nullable(); + t.foreign("sshHostGroupId").references("id").inTable(TableName.SshHostGroup).onDelete("CASCADE"); + t.uuid("sshHostId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGroupColumn = await knex.schema.hasColumn(TableName.SshHostLoginUser, "sshHostGroupId"); + if (hasGroupColumn) { + await knex.schema.alterTable(TableName.SshHostLoginUser, (t) => { + t.dropColumn("sshHostGroupId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.SshHostGroupMembership); + await dropOnUpdateTrigger(knex, TableName.SshHostGroupMembership); + + await knex.schema.dropTableIfExists(TableName.SshHostGroup); + await dropOnUpdateTrigger(knex, TableName.SshHostGroup); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index a69a6463c..b71d51908 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -128,6 +128,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-group-memberships"; +export * from "./ssh-host-groups"; export * from "./ssh-host-login-user-mappings"; export * from "./ssh-host-login-users"; export * from "./ssh-hosts"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e5af10cd6..7fd77da6c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,8 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshHostGroup = "ssh_host_groups", + SshHostGroupMembership = "ssh_host_group_memberships", SshHost = "ssh_hosts", SshHostLoginUser = "ssh_host_login_users", SshHostLoginUserMapping = "ssh_host_login_user_mappings", diff --git a/backend/src/db/schemas/ssh-host-group-memberships.ts b/backend/src/db/schemas/ssh-host-group-memberships.ts new file mode 100644 index 000000000..80a891e07 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-group-memberships.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 SshHostGroupMembershipsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostGroupId: z.string().uuid(), + sshHostId: z.string().uuid() +}); + +export type TSshHostGroupMemberships = z.infer; +export type TSshHostGroupMembershipsInsert = Omit, TImmutableDBKeys>; +export type TSshHostGroupMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-host-groups.ts b/backend/src/db/schemas/ssh-host-groups.ts new file mode 100644 index 000000000..5476e7fa1 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-groups.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 SshHostGroupsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + name: z.string() +}); + +export type TSshHostGroups = z.infer; +export type TSshHostGroupsInsert = Omit, TImmutableDBKeys>; +export type TSshHostGroupsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts index 62454d3c9..6060db903 100644 --- a/backend/src/db/schemas/ssh-host-login-users.ts +++ b/backend/src/db/schemas/ssh-host-login-users.ts @@ -11,8 +11,9 @@ export const SshHostLoginUsersSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - sshHostId: z.string().uuid(), - loginUser: z.string() + sshHostId: z.string().uuid().nullable().optional(), + loginUser: z.string(), + sshHostGroupId: z.string().uuid().nullable().optional() }); export type TSshHostLoginUsers = z.infer; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index a88ebf258..0b8c78586 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -34,6 +34,7 @@ import { registerSnapshotRouter } from "./snapshot-router"; import { registerSshCaRouter } from "./ssh-certificate-authority-router"; import { registerSshCertRouter } from "./ssh-certificate-router"; import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; +import { registerSshHostGroupRouter } from "./ssh-host-group-router"; import { registerSshHostRouter } from "./ssh-host-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -88,6 +89,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" }); await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" }); + await sshRouter.register(registerSshHostGroupRouter, { prefix: "/host-groups" }); }, { prefix: "/ssh" } ); diff --git a/backend/src/ee/routes/v1/ssh-host-group-router.ts b/backend/src/ee/routes/v1/ssh-host-group-router.ts new file mode 100644 index 000000000..c6f35c7e1 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-host-group-router.ts @@ -0,0 +1,360 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; +import { EHostGroupMembershipFilter } from "@app/ee/services/ssh-host-group/ssh-host-group-types"; +import { ApiDocsTags, SSH_HOST_GROUPS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:sshHostGroupId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Get SSH Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.getSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.GET_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Create SSH Host Group", + body: z.object({ + projectId: z.string().describe(SSH_HOST_GROUPS.CREATE.projectId), + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.CREATE.name), + loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOST_GROUPS.CREATE.loginMappings) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.createSshHostGroup({ + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.CREATE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshHostGroupId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Update SSH Host Group", + params: z.object({ + sshHostGroupId: z.string().trim().describe(SSH_HOST_GROUPS.UPDATE.sshHostGroupId) + }), + body: z.object({ + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.UPDATE.name).optional(), + loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOST_GROUPS.UPDATE.loginMappings) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.updateSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.UPDATE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostGroupId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Delete SSH Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE.sshHostGroupId) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.deleteSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.DELETE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostGroupId/hosts", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Get SSH Hosts in a Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) + }), + querystring: z.object({ + filter: z.nativeEnum(EHostGroupMembershipFilter).optional().describe(SSH_HOST_GROUPS.GET.filter) + }), + response: { + 200: z.object({ + hosts: sanitizedSshHost + .pick({ + id: true, + hostname: true, + alias: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sshHostGroup, hosts, totalCount } = await server.services.sshHostGroup.listSshHostGroupHosts({ + sshHostGroupId: req.params.sshHostGroupId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.GET_SSH_HOST_GROUP_HOSTS, + metadata: { + sshHostGroupId: req.params.sshHostGroupId, + name: sshHostGroup.name + } + } + }); + + return { hosts, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostGroupId/hosts/:hostId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Add an SSH Host to a Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.sshHostGroupId), + hostId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.hostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sshHostGroup, sshHost } = await server.services.sshHostGroup.addHostToSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + hostId: req.params.hostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHost.projectId, + event: { + type: EventType.ADD_HOST_TO_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + sshHostId: sshHost.id, + hostname: sshHost.hostname + } + } + }); + + return sshHost; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostGroupId/hosts/:hostId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Remove an SSH Host from a Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.sshHostGroupId), + hostId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.hostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sshHostGroup, sshHost } = await server.services.sshHostGroup.removeHostFromSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + hostId: req.params.hostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHost.projectId, + event: { + type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + sshHostId: sshHost.id, + hostname: sshHost.hostname + } + } + }); + + return sshHost; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 9db642d4d..93748c27f 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -3,8 +3,9 @@ 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 { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; -import { SSH_HOSTS } from "@app/lib/api-docs"; +import { ApiDocsTags, SSH_HOSTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { publicSshCaLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -21,10 +22,16 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], response: { 200: z.array( sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) ) } @@ -49,12 +56,18 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId) }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -91,7 +104,9 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - description: "Add an SSH Host", + hide: false, + tags: [ApiDocsTags.SshHosts], + description: "Register SSH Host", body: z.object({ projectId: z.string().describe(SSH_HOSTS.CREATE.projectId), hostname: z @@ -119,7 +134,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -163,6 +182,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Update SSH Host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId) @@ -192,7 +213,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -235,12 +260,19 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], + description: "Delete SSH Host", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId) }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -278,6 +310,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Issue SSH certificate for user", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId) @@ -350,6 +384,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Issue SSH certificate for host", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId) @@ -414,6 +450,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: publicSshCaLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Get public key of the user SSH CA linked to the host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId) @@ -435,6 +473,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: publicSshCaLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Get public key of the host SSH CA linked to the host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId) 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 9142212a4..1f4badfb5 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -12,6 +12,7 @@ import { import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; +import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; import { TProjectPermission } from "@app/lib/types"; @@ -192,12 +193,19 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + GET_SSH_HOST = "get-ssh-host", CREATE_SSH_HOST = "create-ssh-host", UPDATE_SSH_HOST = "update-ssh-host", DELETE_SSH_HOST = "delete-ssh-host", - GET_SSH_HOST = "get-ssh-host", ISSUE_SSH_HOST_USER_CERT = "issue-ssh-host-user-cert", ISSUE_SSH_HOST_HOST_CERT = "issue-ssh-host-host-cert", + GET_SSH_HOST_GROUP = "get-ssh-host-group", + CREATE_SSH_HOST_GROUP = "create-ssh-host-group", + UPDATE_SSH_HOST_GROUP = "update-ssh-host-group", + DELETE_SSH_HOST_GROUP = "delete-ssh-host-group", + GET_SSH_HOST_GROUP_HOSTS = "get-ssh-host-group-hosts", + ADD_HOST_TO_SSH_HOST_GROUP = "add-host-to-ssh-host-group", + REMOVE_HOST_FROM_SSH_HOST_GROUP = "remove-host-from-ssh-host-group", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -1512,12 +1520,7 @@ interface CreateSshHost { alias: string | null; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; userSshCaId: string; hostSshCaId: string; }; @@ -1531,12 +1534,7 @@ interface UpdateSshHost { alias?: string | null; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; userSshCaId?: string; hostSshCaId?: string; }; @@ -1580,6 +1578,66 @@ interface IssueSshHostHostCert { }; } +interface GetSshHostGroupEvent { + type: EventType.GET_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface CreateSshHostGroupEvent { + type: EventType.CREATE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + loginMappings: TLoginMapping[]; + }; +} + +interface UpdateSshHostGroupEvent { + type: EventType.UPDATE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name?: string; + loginMappings?: TLoginMapping[]; + }; +} + +interface DeleteSshHostGroupEvent { + type: EventType.DELETE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface GetSshHostGroupHostsEvent { + type: EventType.GET_SSH_HOST_GROUP_HOSTS; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface AddHostToSshHostGroupEvent { + type: EventType.ADD_HOST_TO_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + sshHostId: string; + hostname: string; + }; +} + +interface RemoveHostFromSshHostGroupEvent { + type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + sshHostId: string; + hostname: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -2828,6 +2886,13 @@ export type Event = | CreateAppConnectionEvent | UpdateAppConnectionEvent | DeleteAppConnectionEvent + | GetSshHostGroupEvent + | CreateSshHostGroupEvent + | UpdateSshHostGroupEvent + | DeleteSshHostGroupEvent + | GetSshHostGroupHostsEvent + | AddHostToSshHostGroupEvent + | RemoveHostFromSshHostGroupEvent | CreateSharedSecretEvent | DeleteSharedSecretEvent | ReadSharedSecretEvent diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 59f82c05d..2458454da 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -153,7 +153,7 @@ export const groupDALFactory = (db: TDbClient) => { totalCount: Number(members?.[0]?.total_count ?? 0) }; } catch (error) { - throw new DatabaseError({ error, name: "Find all org members" }); + throw new DatabaseError({ error, name: "Find all user group members" }); } }; diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 360b39f28..6a8f807ad 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => { has_used_trial: true, secretApproval: true, secretRotation: true, - caCrl: false + caCrl: false, + sshHostGroups: false }; }; diff --git a/backend/src/ee/services/license/licence-enums.ts b/backend/src/ee/services/license/licence-enums.ts index 047eb0a38..8812621f2 100644 --- a/backend/src/ee/services/license/licence-enums.ts +++ b/backend/src/ee/services/license/licence-enums.ts @@ -10,6 +10,7 @@ export const BillingPlanRows = { CustomAlerts: { name: "Custom alerts", field: "customAlerts" }, AuditLogs: { name: "Audit logs", field: "auditLogs" }, SamlSSO: { name: "SAML SSO", field: "samlSSO" }, + SshHostGroups: { name: "SSH Host Groups", field: "sshHostGroups" }, Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" }, OidcSSO: { name: "OIDC SSO", field: "oidcSSO" }, SecretApproval: { name: "Secret approvals", field: "secretApproval" }, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 548f6e82b..b7ae6f7ee 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -53,7 +53,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enforceMfa: false, projectTemplates: false, kmip: false, - gateway: false + gateway: false, + sshHostGroups: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 6f0d82344..358849fb2 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -71,6 +71,7 @@ export type TFeatureSet = { projectTemplates: false; kmip: false; gateway: false; + sshHostGroups: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 8e6645073..319a0259a 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -134,6 +134,7 @@ export enum ProjectPermissionSub { SshCertificates = "ssh-certificates", SshCertificateTemplates = "ssh-certificate-templates", SshHosts = "ssh-hosts", + SshHostGroups = "ssh-host-groups", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -240,6 +241,7 @@ export type ProjectPermissionSet = ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields) ] + | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] @@ -508,6 +510,12 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshHostGroups).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -686,7 +694,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, - ProjectPermissionSub.SshCertificateTemplates + ProjectPermissionSub.SshCertificateTemplates, + ProjectPermissionSub.SshHostGroups ].forEach((el) => { can( [ diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts new file mode 100644 index 000000000..08242d4cb --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts @@ -0,0 +1,225 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { ormify } from "@app/lib/knex"; + +import { EHostGroupMembershipFilter } from "./ssh-host-group-types"; + +export type TSshHostGroupDALFactory = ReturnType; + +export const sshHostGroupDALFactory = (db: TDbClient) => { + const sshHostGroupOrm = ormify(db, TableName.SshHostGroup); + + const findSshHostGroupsWithLoginMappings = async (projectId: string, tx?: Knex) => { + try { + // First, get all the SSH host groups with their login mappings + const rows = await (tx || db.replicaNode())(TableName.SshHostGroup) + .leftJoin( + TableName.SshHostLoginUser, + `${TableName.SshHostGroup}.id`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroup}.projectId`, projectId) + .select( + db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), + db.ref("projectId").withSchema(TableName.SshHostGroup), + db.ref("name").withSchema(TableName.SshHostGroup), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + ) + .orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc"); + + const hostsGrouped = groupBy(rows, (r) => r.sshHostGroupId); + + const hostGroupIds = Object.keys(hostsGrouped); + + type HostCountRow = { + sshHostGroupId: string; + host_count: string; + }; + + const hostCountsQuery = (await (tx || + db + .replicaNode()(TableName.SshHostGroupMembership) + .select(`${TableName.SshHostGroupMembership}.sshHostGroupId`, db.raw(`count(*) as host_count`)) + .whereIn(`${TableName.SshHostGroupMembership}.sshHostGroupId`, hostGroupIds) + .groupBy(`${TableName.SshHostGroupMembership}.sshHostGroupId`))) as HostCountRow[]; + + const hostCountsMap = hostCountsQuery.reduce>((acc, { sshHostGroupId, host_count }) => { + acc[sshHostGroupId] = Number(host_count); + return acc; + }, {}); + + return Object.values(hostsGrouped).map((hostRows) => { + const { sshHostGroupId, name } = hostRows[0]; + const loginMappingGrouped = groupBy( + hostRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + return { + id: sshHostGroupId, + projectId, + name, + loginMappings, + hostCount: hostCountsMap[sshHostGroupId] ?? 0 + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupsWithLoginMappings` }); + } + }; + + const findSshHostGroupByIdWithLoginMappings = async (sshHostGroupId: string, tx?: Knex) => { + try { + const rows = await (tx || db.replicaNode())(TableName.SshHostGroup) + .leftJoin( + TableName.SshHostLoginUser, + `${TableName.SshHostGroup}.id`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroup}.id`, sshHostGroupId) + .select( + db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), + db.ref("projectId").withSchema(TableName.SshHostGroup), + db.ref("name").withSchema(TableName.SshHostGroup), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + ); + + if (rows.length === 0) return null; + + const { sshHostGroupId: id, projectId, name } = rows[0]; + + const loginMappingGrouped = groupBy( + rows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + + return { + id, + projectId, + name, + loginMappings + }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupByIdWithLoginMappings` }); + } + }; + + const findAllSshHostsInGroup = async ({ + sshHostGroupId, + offset = 0, + limit, + filter + }: { + sshHostGroupId: string; + offset?: number; + limit?: number; + filter?: EHostGroupMembershipFilter; + }) => { + try { + const sshHostGroup = await db + .replicaNode()(TableName.SshHostGroup) + .where(`${TableName.SshHostGroup}.id`, sshHostGroupId) + .select("projectId") + .first(); + + if (!sshHostGroup) { + throw new BadRequestError({ + message: `SSH host group with ID ${sshHostGroupId} not found` + }); + } + + const query = db + .replicaNode()(TableName.SshHost) + .where(`${TableName.SshHost}.projectId`, sshHostGroup.projectId) + .leftJoin(TableName.SshHostGroupMembership, (bd) => { + bd.on(`${TableName.SshHostGroupMembership}.sshHostId`, "=", `${TableName.SshHost}.id`).andOn( + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + "=", + db.raw("?", [sshHostGroupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("alias").withSchema(TableName.SshHost), + db.ref("sshHostGroupId").withSchema(TableName.SshHostGroupMembership), + db.ref("createdAt").withSchema(TableName.SshHostGroupMembership).as("joinedGroupAt"), + db.raw(`count(*) OVER() as total_count`) + ) + .offset(offset) + .orderBy(`${TableName.SshHost}.hostname`, "asc"); + + if (limit) { + void query.limit(limit); + } + + if (filter) { + switch (filter) { + case EHostGroupMembershipFilter.GROUP_MEMBERS: + void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is not", null); + break; + case EHostGroupMembershipFilter.NON_GROUP_MEMBERS: + void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + } + + const hosts = await query; + + return { + hosts: hosts.map(({ id, hostname, alias, sshHostGroupId: memberGroupId, joinedGroupAt }) => ({ + id, + hostname, + alias, + isPartOfGroup: !!memberGroupId, + joinedGroupAt + })), + // @ts-expect-error col select is raw and not strongly typed + totalCount: Number(hosts?.[0]?.total_count ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHostGroupMembership}: FindAllSshHostsInGroup` }); + } + }; + + return { + findSshHostGroupsWithLoginMappings, + findSshHostGroupByIdWithLoginMappings, + findAllSshHostsInGroup, + ...sshHostGroupOrm + }; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts new file mode 100644 index 000000000..54179c2d9 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-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 TSshHostGroupMembershipDALFactory = ReturnType; + +export const sshHostGroupMembershipDALFactory = (db: TDbClient) => { + const sshHostGroupMembershipOrm = ormify(db, TableName.SshHostGroupMembership); + + return { + ...sshHostGroupMembershipOrm + }; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts new file mode 100644 index 000000000..4ebf3000d --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts @@ -0,0 +1,7 @@ +import { SshHostGroupsSchema } from "@app/db/schemas"; + +export const sanitizedSshHostGroup = SshHostGroupsSchema.pick({ + id: true, + projectId: true, + name: true +}); diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts new file mode 100644 index 000000000..751116895 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -0,0 +1,397 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-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 { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; +import { TSshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; +import { + TAddHostToSshHostGroupDTO, + TCreateSshHostGroupDTO, + TDeleteSshHostGroupDTO, + TGetSshHostGroupDTO, + TListSshHostGroupHostsDTO, + TRemoveHostFromSshHostGroupDTO, + TUpdateSshHostGroupDTO +} from "./ssh-host-group-types"; + +type TSshHostGroupServiceFactoryDep = { + projectDAL: Pick; + sshHostDAL: Pick; + sshHostGroupDAL: Pick< + TSshHostGroupDALFactory, + | "create" + | "updateById" + | "findById" + | "deleteById" + | "transaction" + | "findSshHostGroupByIdWithLoginMappings" + | "findAllSshHostsInGroup" + | "findOne" + | "find" + >; + sshHostGroupMembershipDAL: Pick; + sshHostLoginUserDAL: Pick; + sshHostLoginUserMappingDAL: Pick; + userDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TSshHostGroupServiceFactory = ReturnType; + +export const sshHostGroupServiceFactory = ({ + projectDAL, + sshHostDAL, + sshHostGroupDAL, + sshHostGroupMembershipDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + licenseService +}: TSshHostGroupServiceFactoryDep) => { + const createSshHostGroup = async ({ + projectId, + name, + loginMappings, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshHostGroupDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHostGroups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.sshHostGroups) + throw new BadRequestError({ + message: "Failed to create SSH host group due to plan restriction. Upgrade plan to create group." + }); + + const newSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { + // (dangtony98): room to optimize check to ensure that + // the SSH host group name is unique across the whole org + const project = await projectDAL.findById(projectId, tx); + if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); + const projects = await projectDAL.find( + { + orgId: project.orgId + }, + { tx } + ); + + const existingSshHostGroup = await sshHostGroupDAL.find( + { + name, + $in: { + projectId: projects.map((p) => p.id) + } + }, + { tx } + ); + + if (existingSshHostGroup.length) { + throw new BadRequestError({ + message: `SSH host group with name '${name}' already exists in the organization` + }); + } + + const sshHostGroup = await sshHostGroupDAL.create( + { + projectId, + name + }, + tx + ); + + await createSshLoginMappings({ + sshHostGroupId: sshHostGroup.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId, + actorAuthMethod, + actorOrgId, + tx + }); + + const newSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings( + sshHostGroup.id, + tx + ); + if (!newSshHostGroupWithLoginMappings) { + throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` }); + } + + return newSshHostGroupWithLoginMappings; + }); + + return newSshHostGroup; + }; + + const updateSshHostGroup = async ({ + sshHostGroupId, + name, + loginMappings, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findById(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.sshHostGroups) + throw new BadRequestError({ + message: "Failed to update SSH host group due to plan restriction. Upgrade plan to update group." + }); + + const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { + await sshHostGroupDAL.updateById( + sshHostGroupId, + { + name + }, + tx + ); + if (loginMappings) { + await sshHostLoginUserDAL.delete({ sshHostGroupId: sshHostGroup.id }, tx); + if (loginMappings.length) { + await createSshLoginMappings({ + sshHostGroupId: sshHostGroup.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + tx + }); + } + } + + const updatedSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings( + sshHostGroup.id, + tx + ); + if (!updatedSshHostGroupWithLoginMappings) { + throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` }); + } + + return updatedSshHostGroupWithLoginMappings; + }); + + return updatedSshHostGroup; + }; + + const getSshHostGroup = async ({ + sshHostGroupId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); + + return sshHostGroup; + }; + + const deleteSshHostGroup = async ({ + sshHostGroupId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SshHostGroups); + + await sshHostGroupDAL.deleteById(sshHostGroupId); + + return sshHostGroup; + }; + + const listSshHostGroupHosts = async ({ + sshHostGroupId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + filter + }: TListSshHostGroupHostsDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); + + const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId, filter }); + return { sshHostGroup, hosts, totalCount }; + }; + + const addHostToSshHostGroup = async ({ + sshHostGroupId, + hostId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TAddHostToSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!sshHost) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found` + }); + } + + if (sshHostGroup.projectId !== sshHost.projectId) { + throw new BadRequestError({ + message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + + await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId }); + + return { sshHostGroup, sshHost }; + }; + + const removeHostFromSshHostGroup = async ({ + sshHostGroupId, + hostId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRemoveHostFromSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!sshHost) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found` + }); + } + + if (sshHostGroup.projectId !== sshHost.projectId) { + throw new BadRequestError({ + message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + + const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({ + sshHostGroupId, + sshHostId: hostId + }); + + if (!sshHostGroupMembership) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found in SSH host group with ID ${sshHostGroupId}` + }); + } + + await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id); + + return { sshHostGroup, sshHost }; + }; + + return { + createSshHostGroup, + getSshHostGroup, + deleteSshHostGroup, + updateSshHostGroup, + listSshHostGroupHosts, + addHostToSshHostGroup, + removeHostFromSshHostGroup + }; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts new file mode 100644 index 000000000..3485b5d26 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts @@ -0,0 +1,46 @@ +import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateSshHostGroupDTO = { + name: string; + loginMappings: TLoginMapping[]; +} & TProjectPermission; + +export type TUpdateSshHostGroupDTO = { + sshHostGroupId: string; + name?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +} & Omit; + +export type TGetSshHostGroupDTO = { + sshHostGroupId: string; +} & Omit; + +export type TDeleteSshHostGroupDTO = { + sshHostGroupId: string; +} & Omit; + +export type TListSshHostGroupHostsDTO = { + sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; +} & Omit; + +export type TAddHostToSshHostGroupDTO = { + sshHostGroupId: string; + hostId: string; +} & Omit; + +export type TRemoveHostFromSshHostGroupDTO = { + sshHostGroupId: string; + hostId: string; +} & Omit; + +export enum EHostGroupMembershipFilter { + GROUP_MEMBERS = "group-members", + NON_GROUP_MEMBERS = "non-group-members" +} 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 3c9755e65..e66f7da7a 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -6,6 +6,8 @@ import { DatabaseError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { ormify } from "@app/lib/knex"; +import { LoginMappingSource } from "./ssh-host-types"; + export type TSshHostDALFactory = ReturnType; export const sshHostDALFactory = (db: TDbClient) => { @@ -13,20 +15,22 @@ export const sshHostDALFactory = (db: TDbClient) => { const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => { try { - const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first(); + const knex = tx || db.replicaNode(); + + const user = await knex(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) + // get hosts where user has direct login mappings + const directHostRows = await knex(TableName.SshHost) .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) .leftJoin( TableName.SshHostLoginUserMapping, `${TableName.SshHostLoginUser}.id`, `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) - .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) .whereIn(`${TableName.SshHost}.projectId`, projectIds) .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) .select( @@ -37,26 +41,70 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost), 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(rows, (r) => r.sshHostId); - return Object.values(grouped).map((hostRows) => { + // get hosts where user has login mappings via host groups + const groupHostRows = await knex(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .whereIn(`${TableName.SshHost}.projectId`, projectIds) + .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("alias").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ); + + const directHostRowsWithSource = directHostRows.map((row) => ({ + ...row, + source: LoginMappingSource.HOST + })); + + const groupHostRowsWithSource = groupHostRows.map((row) => ({ + ...row, + source: LoginMappingSource.HOST_GROUP + })); + + const mergedRows = [...directHostRowsWithSource, ...groupHostRowsWithSource]; + + const hostsGrouped = groupBy(mergedRows, (r) => r.sshHostId); + + return Object.values(hostsGrouped).map((hostRows) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, mappings]) => { + // Prefer HOST source over HOST_GROUP + const preferredMapping = + mappings.find((m) => m.source === LoginMappingSource.HOST) || + mappings.find((m) => m.source === LoginMappingSource.HOST_GROUP); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ - loginUser, - allowedPrincipals: { - usernames: [user.username] - } - })); + return { + loginUser, + allowedPrincipals: { + usernames: [user.username] + }, + source: preferredMapping!.source + }; + }); return { id: sshHostId, @@ -101,20 +149,57 @@ export const sshHostDALFactory = (db: TDbClient) => { ) .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + // process login mappings inherited from groups that hosts are part of + const hostIds = unique(rows.map((r) => r.sshHostId)).filter(Boolean); + const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .select( + db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users) + ) + .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds); + + const groupedGroupMappings = groupBy(groupRows, (r) => r.sshHostId); + const hostsGrouped = groupBy(rows, (r) => r.sshHostId); return Object.values(hostsGrouped).map((hostRows) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; + // direct login mappings const loginMappingGrouped = groupBy( hostRows.filter((r) => r.loginUser), (r) => r.loginUser ); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { usernames: unique(entries.map((e) => e.username)).filter(Boolean) - } + }, + source: LoginMappingSource.HOST + })); + + // group-inherited login mappings + const inheritedGroupRows = groupedGroupMappings[sshHostId] || []; + const inheritedGrouped = groupBy(inheritedGroupRows, (r) => r.loginUser); + + const groupMappings = Object.entries(inheritedGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + }, + source: LoginMappingSource.HOST_GROUP })); return { @@ -124,7 +209,7 @@ export const sshHostDALFactory = (db: TDbClient) => { projectId, userCertTtl, hostCertTtl, - loginMappings, + loginMappings: [...directMappings, ...groupMappings], userSshCaId, hostSshCaId }; @@ -163,16 +248,50 @@ export const sshHostDALFactory = (db: TDbClient) => { const { sshHostId: id, projectId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; - const loginMappingGrouped = groupBy( + // direct login mappings + const directGrouped = groupBy( rows.filter((r) => r.loginUser), (r) => r.loginUser ); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { usernames: unique(entries.map((e) => e.username)).filter(Boolean) - } + }, + source: LoginMappingSource.HOST + })); + + // group login mappings + const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId) + .select( + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users) + ); + + const groupGrouped = groupBy( + groupRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const groupMappings = Object.entries(groupGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + }, + source: LoginMappingSource.HOST_GROUP })); return { @@ -182,7 +301,7 @@ export const sshHostDALFactory = (db: TDbClient) => { alias, userCertTtl, hostCertTtl, - loginMappings, + loginMappings: [...directMappings, ...groupMappings], userSshCaId, hostSshCaId }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-fns.ts b/backend/src/ee/services/ssh-host/ssh-host-fns.ts new file mode 100644 index 000000000..9b9ce2642 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-fns.ts @@ -0,0 +1,85 @@ +import { Knex } from "knex"; + +import { ActionProjectType } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; + +import { TCreateSshLoginMappingsDTO } from "./ssh-host-types"; + +/** + * Create SSH login mappings for a given SSH host + * or SSH host group. + */ +export const createSshLoginMappings = async ({ + sshHostId, + sshHostGroupId, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId, + actorAuthMethod, + actorOrgId, + tx: outerTx +}: TCreateSshLoginMappingsDTO) => { + const processCreation = async (tx: Knex) => { + // (dangtony98): room to optimize + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + // (dangtony98): should either pass in sshHostId or sshHostGroupId but not both + { + sshHostId, + sshHostGroupId, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length > 0) { + 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) { + // check that each user has access to the SSH project + 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 + ); + } + } + }; + + if (outerTx) { + return processCreation(outerTx); + } + + return sshHostLoginUserDAL.transaction(processCreation); +}; 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 92f1f5236..87f4862bb 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -26,6 +26,7 @@ import { getSshPublicKey } from "../ssh/ssh-certificate-authority-fns"; import { SshCertType } from "../ssh/ssh-certificate-authority-types"; +import { createSshLoginMappings } from "./ssh-host-fns"; import { TCreateSshHostDTO, TDeleteSshHostDTO, @@ -202,56 +203,18 @@ export const sshHostServiceFactory = ({ tx ); - // (dangtony98): room to optimize - for await (const { loginUser, allowedPrincipals } of loginMappings) { - const sshHostLoginUser = await sshHostLoginUserDAL.create( - { - sshHostId: host.id, - loginUser - }, - tx - ); - - if (allowedPrincipals.usernames.length > 0) { - 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) { - // check that each user has access to the SSH project - 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 - ); - } - } + await createSshLoginMappings({ + sshHostId: host.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId, + actorAuthMethod, + actorOrgId, + tx + }); const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); if (!newSshHostWithLoginMappings) { @@ -310,54 +273,18 @@ export const sshHostServiceFactory = ({ if (loginMappings) { await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx); if (loginMappings.length) { - for await (const { loginUser, allowedPrincipals } of loginMappings) { - const sshHostLoginUser = await sshHostLoginUserDAL.create( - { - sshHostId: host.id, - loginUser - }, - tx - ); - - if (allowedPrincipals.usernames.length > 0) { - 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 - ); - } - } + await createSshLoginMappings({ + sshHostId: host.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + tx + }); } } 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 a4826cd72..9846920b7 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -1,18 +1,32 @@ +import { Knex } from "knex"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +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 { TProjectPermission } from "@app/lib/types"; +import { ActorAuthMethod } from "@app/services/auth/auth-type"; +import { TUserDALFactory } from "@app/services/user/user-dal"; export type TListSshHostsDTO = Omit; +export type TLoginMapping = { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; +}; + +export enum LoginMappingSource { + HOST = "host", + HOST_GROUP = "hostGroup" +} + export type TCreateSshHostDTO = { hostname: string; alias?: string; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; userSshCaId?: string; hostSshCaId?: string; } & TProjectPermission; @@ -23,12 +37,7 @@ export type TUpdateSshHostDTO = { alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; } & Omit; export type TGetSshHostDTO = { @@ -48,3 +57,19 @@ export type TIssueSshHostHostCertDTO = { sshHostId: string; publicKey: string; } & Omit; + +type BaseCreateSshLoginMappingsDTO = { + loginMappings: TLoginMapping[]; + sshHostLoginUserDAL: Pick; + sshHostLoginUserMappingDAL: Pick; + userDAL: Pick; + permissionService: Pick; + projectId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + tx?: Knex; +}; + +export type TCreateSshLoginMappingsDTO = + | (BaseCreateSshLoginMappingsDTO & { sshHostId: string; sshHostGroupId?: undefined }) + | (BaseCreateSshLoginMappingsDTO & { sshHostGroupId: string; sshHostId?: undefined }); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index dfb23a6c3..10454ab9b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -48,6 +48,8 @@ export enum ApiDocsTags { SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", + SshHosts = "SSH Hosts", + SshHostGroups = "SSH Host Groups", KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", KmsSigning = "KMS Signing" @@ -568,6 +570,9 @@ export const PROJECTS = { LIST_SSH_HOSTS: { projectId: "The ID of the project to list SSH hosts for." }, + LIST_SSH_HOST_GROUPS: { + projectId: "The ID of the project to list SSH host groups for." + }, LIST_SSH_CERTIFICATES: { projectId: "The ID of the project to list SSH certificates for.", offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", @@ -1382,6 +1387,40 @@ export const SSH_CERTIFICATE_TEMPLATES = { } }; +export const SSH_HOST_GROUPS = { + GET: { + sshHostGroupId: "The ID of the SSH host group to get.", + filter: "The filter to apply to the SSH hosts in the SSH host group." + }, + CREATE: { + projectId: "The ID of the project to create the SSH host group in.", + name: "The name of the SSH host group.", + loginMappings: + "A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + }, + UPDATE: { + sshHostGroupId: "The ID of the SSH host group to update.", + name: "The name of the SSH host group to update to.", + loginMappings: + "A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + }, + DELETE: { + sshHostGroupId: "The ID of the SSH host group to delete." + }, + LIST_HOSTS: { + offset: "The offset to start from. If you enter 10, it will start from the 10th host", + limit: "The number of hosts to return." + }, + ADD_HOST: { + sshHostGroupId: "The ID of the SSH host group to add the host to.", + hostId: "The ID of the SSH host to add to the SSH host group." + }, + DELETE_HOST: { + sshHostGroupId: "The ID of the SSH host group to delete the host from.", + hostId: "The ID of the SSH host to delete from the SSH host group." + } +}; + export const SSH_HOSTS = { GET: { sshHostId: "The ID of the SSH host to get." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index fb9c2fc32..e10f49794 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -103,6 +103,9 @@ import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-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 { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; +import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; +import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; 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"; @@ -403,6 +406,8 @@ export const registerRoutes = async ( const sshHostDAL = sshHostDALFactory(db); const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db); const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db); + const sshHostGroupDAL = sshHostGroupDALFactory(db); + const sshHostGroupMembershipDAL = sshHostGroupMembershipDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); @@ -634,6 +639,7 @@ export const registerRoutes = async ( tokenService, orgDAL, totpService, + orgMembershipDAL, auditLogService }); const passwordService = authPaswordServiceFactory({ @@ -871,6 +877,18 @@ export const registerRoutes = async ( kmsService }); + const sshHostGroupService = sshHostGroupServiceFactory({ + projectDAL, + sshHostDAL, + sshHostGroupDAL, + sshHostGroupMembershipDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + licenseService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -1040,6 +1058,7 @@ export const registerRoutes = async ( sshCertificateDAL, sshCertificateTemplateDAL, sshHostDAL, + sshHostGroupDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1698,6 +1717,7 @@ export const registerRoutes = async ( sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, sshHost: sshHostService, + sshHostGroup: sshHostGroupService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index f7a1b973a..b6b3cb8aa 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -23,6 +23,7 @@ import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; export const registerSsoRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -342,8 +343,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }` ); } + + const serverCfg = await getServerCfg(); return res.redirect( - `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + serverCfg.defaultAuthOrgId && !appCfg.isCloud ? `&defaultOrgAllowed=true` : "" + }` ); } }); diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index f7540591e..a223004a9 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -14,6 +14,8 @@ import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-s 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 { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; +import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -631,7 +633,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ hosts: z.array( sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) ) }) @@ -650,4 +656,39 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { hosts }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-host-groups", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOST_GROUPS.projectId) + }), + response: { + 200: z.object({ + groups: z.array( + sanitizedSshHostGroup.extend({ + loginMappings: loginMappingSchema.array(), + hostCount: z.number() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const groups = await server.services.project.listProjectSshHostGroups({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index d9196dc88..552253cde 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -88,24 +88,41 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { rateLimit: authRateLimit }, schema: { - body: z.object({ - email: z.string().trim(), - firstName: z.string().trim(), - lastName: z.string().trim().optional(), - protectedKey: z.string().trim(), - protectedKeyIV: z.string().trim(), - protectedKeyTag: z.string().trim(), - publicKey: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - encryptedPrivateKeyIV: z.string().trim(), - encryptedPrivateKeyTag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim(), - organizationName: GenericResourceNameSchema, - providerAuthToken: z.string().trim().optional().nullish(), - attributionSource: z.string().trim().optional(), - password: z.string() - }), + body: z + .object({ + email: z.string().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + publicKey: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim(), + providerAuthToken: z.string().trim().optional().nullish(), + attributionSource: z.string().trim().optional(), + password: z.string() + }) + .and( + z.preprocess( + (data) => { + if (typeof data === "object" && data && "useDefaultOrg" in data === false) { + return { ...data, useDefaultOrg: false }; + } + return data; + }, + z.discriminatedUnion("useDefaultOrg", [ + z.object({ useDefaultOrg: z.literal(true) }), + z.object({ + useDefaultOrg: z.literal(false), + organizationName: GenericResourceNameSchema + }) + ]) + ) + ), response: { 200: z.object({ message: z.string(), diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index d1b0a550d..14aa8f038 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -2,7 +2,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers, UserDeviceSchema } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; @@ -20,6 +20,8 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; +import { getDefaultOrgMembershipRole } from "../org/org-role-fns"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { LoginMethod } from "../super-admin/super-admin-types"; import { TTotpServiceFactory } from "../totp/totp-service"; @@ -48,6 +50,7 @@ type TAuthLoginServiceFactoryDep = { smtpService: TSmtpService; totpService: Pick; auditLogService: Pick; + orgMembershipDAL: TOrgMembershipDALFactory; }; export type TAuthLoginFactory = ReturnType; @@ -56,6 +59,7 @@ export const authLoginServiceFactory = ({ tokenService, smtpService, orgDAL, + orgMembershipDAL, totpService, auditLogService }: TAuthLoginServiceFactoryDep) => { @@ -719,6 +723,35 @@ export const authLoginServiceFactory = ({ authMethods: [authMethod], isGhost: false }); + + if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { + let orgId = ""; + const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); + if (!defaultOrg) { + throw new BadRequestError({ + message: `Failed to find default organization with ID ${serverCfg.defaultAuthOrgId}` + }); + } + orgId = defaultOrg.id; + const [orgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }); + + if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRole(defaultOrg.defaultMembershipRole); + + await orgMembershipDAL.create({ + userId: user.id, + inviteEmail: email, + orgId, + role, + roleId, + status: OrgMembershipStatus.Accepted, + isActive: true + }); + } + } } else { const isLinkingRequired = !user?.authMethods?.includes(authMethod); if (isLinkingRequired) { diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 58ba9186e..4d8c98205 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -9,7 +9,7 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { getMinExpiresIn } from "@app/lib/fn"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; @@ -150,7 +150,8 @@ export const authSignupServiceFactory = ({ encryptedPrivateKeyTag, ip, userAgent, - authorization + authorization, + useDefaultOrg }: TCompleteAccountSignupDTO) => { const appCfg = getConfig(); const serverCfg = await getServerCfg(); @@ -293,15 +294,24 @@ export const authSignupServiceFactory = ({ }); if (!organizationId) { - const newOrganization = await orgService.createOrganization({ - userId: user.id, - userEmail: user.email ?? user.username, - orgName: organizationName - }); + let orgId = ""; + if (useDefaultOrg && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { + const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); + if (!defaultOrg) throw new BadRequestError({ message: "Failed to find default organization" }); + orgId = defaultOrg.id; + } else { + if (!organizationName) throw new BadRequestError({ message: "Organization name is required" }); + const newOrganization = await orgService.createOrganization({ + userId: user.id, + userEmail: user.email ?? user.username, + orgName: organizationName + }); - if (!newOrganization) throw new Error("Failed to create organization"); + if (!newOrganization) throw new Error("Failed to create organization"); + orgId = newOrganization.id; + } - organizationId = newOrganization.id; + organizationId = orgId; } const updatedMembersips = await orgDAL.updateMembership( diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index 3308b9d12..8bbf302c5 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -12,12 +12,13 @@ export type TCompleteAccountSignupDTO = { encryptedPrivateKeyTag: string; salt: string; verifier: string; - organizationName: string; + organizationName?: string; providerAuthToken?: string | null; attributionSource?: string | undefined; ip: string; userAgent: string; authorization: string; + useDefaultOrg?: boolean; }; export type TCompleteAccountInviteDTO = { diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 73a439055..8e60252ba 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -25,6 +25,7 @@ import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/s import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -153,12 +154,12 @@ type TProjectServiceFactoryDep = { sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; sshHostDAL: Pick; + sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; queueService: Pick; smtpService: Pick; - orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; @@ -210,6 +211,7 @@ export const projectServiceFactory = ({ sshCertificateDAL, sshCertificateTemplateDAL, sshHostDAL, + sshHostGroupDAL, keyStore, kmsService, projectBotDAL, @@ -1162,6 +1164,32 @@ export const projectServiceFactory = ({ return allowedHosts; }; + /** + * Return list of SSH host groups for project + */ + const listProjectSshHostGroups = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshHostsDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); + + const sshHostGroups = await sshHostGroupDAL.findSshHostGroupsWithLoginMappings(projectId); + + return sshHostGroups; + }; + /** * Return list of SSH certificates for project */ @@ -1892,6 +1920,7 @@ export const projectServiceFactory = ({ listProjectCertificateTemplates, listProjectSshCas, listProjectSshHosts, + listProjectSshHostGroups, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/create-permanent.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/create-permanent.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/create-temporary.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/create-temporary.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/delete.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/delete.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/delete.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx similarity index 70% rename from docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx index a6ec27217..cdf3ba5ea 100644 --- a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx +++ b/docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx @@ -1,4 +1,4 @@ --- -title: "Find By Privilege Slug" +title: "Find By Slug" openapi: "GET /api/v1/additional-privilege/identity/{privilegeSlug}" --- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/list.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/list.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/list.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/update.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/update.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/update.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx new file mode 100644 index 000000000..7ff358b40 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/identity-project-additional-privilege" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx new file mode 100644 index 000000000..68d6596e3 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/identity-project-additional-privilege/{id}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx new file mode 100644 index 000000000..d9d40a473 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Find By ID" +openapi: "GET /api/v2/identity-project-additional-privilege/{id}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx new file mode 100644 index 000000000..290e332ed --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Find By Slug" +openapi: "GET /api/v2/identity-project-additional-privilege/slug/{privilegeSlug}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx new file mode 100644 index 000000000..33a4673d9 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/identity-project-additional-privilege" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx new file mode 100644 index 000000000..b0dd9b4b2 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/identity-project-additional-privilege/{id}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/add-host.mdx b/docs/api-reference/endpoints/ssh/groups/add-host.mdx new file mode 100644 index 000000000..9f903eccd --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/add-host.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Host" +openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/create.mdx b/docs/api-reference/endpoints/ssh/groups/create.mdx new file mode 100644 index 000000000..be4755d8e --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/host-groups" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/delete.mdx b/docs/api-reference/endpoints/ssh/groups/delete.mdx new file mode 100644 index 000000000..19205f2eb --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx new file mode 100644 index 000000000..2db1fd11f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx @@ -0,0 +1,4 @@ +--- +title: "List Hosts" +openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/list.mdx b/docs/api-reference/endpoints/ssh/groups/list.mdx new file mode 100644 index 000000000..089ab4096 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/read.mdx b/docs/api-reference/endpoints/ssh/groups/read.mdx new file mode 100644 index 000000000..060a75bce --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/remove-host.mdx b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx new file mode 100644 index 000000000..6933e5c9f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Host" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/update.mdx b/docs/api-reference/endpoints/ssh/groups/update.mdx new file mode 100644 index 000000000..3e23bf4f9 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/create.mdx b/docs/api-reference/endpoints/ssh/hosts/create.mdx new file mode 100644 index 000000000..5860b6b49 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/delete.mdx b/docs/api-reference/endpoints/ssh/hosts/delete.mdx new file mode 100644 index 000000000..c52129b33 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx new file mode 100644 index 000000000..bb843e466 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Host Certificate" +openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx new file mode 100644 index 000000000..a16e03a65 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue User Certificate" +openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/list-my.mdx b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx new file mode 100644 index 000000000..2b7ab51c0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx @@ -0,0 +1,4 @@ +--- +title: "List My Hosts" +openapi: "GET /api/v1/ssh/hosts/" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/list.mdx b/docs/api-reference/endpoints/ssh/hosts/list.mdx new file mode 100644 index 000000000..3f61123e0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx new file mode 100644 index 000000000..15ca53e9d --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx @@ -0,0 +1,4 @@ +--- +title: "Read Host CA Public Key" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx new file mode 100644 index 000000000..a6f31e93f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx @@ -0,0 +1,4 @@ +--- +title: "Read User CA Public Key" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read.mdx b/docs/api-reference/endpoints/ssh/hosts/read.mdx new file mode 100644 index 000000000..112296a5a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/update.mdx b/docs/api-reference/endpoints/ssh/hosts/update.mdx new file mode 100644 index 000000000..d5555b813 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/documentation/platform/ssh/host-groups.mdx b/docs/documentation/platform/ssh/host-groups.mdx new file mode 100644 index 000000000..877291183 --- /dev/null +++ b/docs/documentation/platform/ssh/host-groups.mdx @@ -0,0 +1,56 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Host Groups" +description: "Learn how to organize SSH hosts into groups and manage access policies at scale." +--- + +## Concept + +Infisical SSH lets you configure host groups to organize and manage multiple SSH hosts with shared access configuration. +These host groups can be created based on environments (`development`, `staging`, `production`), geographical regions (`us-east`, `eu-west`, `ap-northeast`), or functions (`web-servers`, `database-servers`, `worker-nodes`) to streamline access management across your infrastructure. + +Using a host group, you can define login mappings at the group level and have them be applied to all hosts assigned to that group. For example, you can specify that `john@example.com` can login as `ubuntu` on all hosts assigned to the `production` host group. + +## Workflow + +The typical workflow for using Infisical SSH with host groups consists of the following steps: + +1. The administrator creates host groups based on logical groupings (environments, regions, functions, etc.). +2. The administrator configures login mappings at the host group level to define access policies. +3. The administrator registers remote hosts with Infisical using the Infisical CLI via the `infisical ssh add-host` command and assigns them to appropriate host groups either using the `--host-group` flag or by adding them to the host group via UI. +4. User(s) access the remote hosts using the Infisical CLI via the `infisical ssh connect` command, with access determined by the login mappings defined at both host and host group levels. + +## Admin Guide for Configuring Host Groups + +In the following steps, we'll walk through how to create and configure Host Groups in Infisical SSH, and how to add hosts to these groups. + + + + 1.1. Navigate to your Infisical SSH project and select the **Hosts** tab. + + 1.2. Click **Add Group** in the **Host Groups** section to create a new group. + + Enter a name (e.g., `production-servers` or `tokyo-region`) and login mapping(s) for the host group. + + A login mapping for a host group applies to all hosts assigned to the group and dictates what user(s) will be allowed access to the remote hosts + in that group under specific login user(s); in the allowed principals, you should select user(s) part of the Infisical SSH project that will + be allowed to login to the remote host as the login user. + + For instance, if you add a mapping to a host group with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to any remote host that is part of the group as `ec2-user` which is a system user that + exists on the remote host(s). + + ![ssh group add group](/images/platform/ssh/v2/ssh-group-add-group-1.png) + ![ssh group add group 2](/images/platform/ssh/v2/ssh-group-add-group-2.png) + + 1.3. Click **Add** to create the host group. + + + + + After creating the host group, you can assign a host to it from inside the host group page in the **SSH Hosts** section. Generally, this is where you'll manage the hosts in a group. + + ![ssh group add group - add host](/images/platform/ssh/v2/ssh-group-add-host-1.png) + ![ssh group add group - added host](/images/platform/ssh/v2/ssh-group-add-host-2.png) + + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh/overview.mdx similarity index 99% rename from docs/documentation/platform/ssh.mdx rename to docs/documentation/platform/ssh/overview.mdx index 2bc433f75..e71eeabe1 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -1,6 +1,6 @@ --- title: "Infisical SSH" -sidebarTitle: "Infisical SSH" +sidebarTitle: "Overview" description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." --- diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-1.png b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png new file mode 100644 index 000000000..be023335f Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-2.png b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png new file mode 100644 index 000000000..0507d6e99 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-1.png b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png new file mode 100644 index 000000000..de8568f61 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-2.png b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png new file mode 100644 index 000000000..79143c4da Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png differ diff --git a/docs/mint.json b/docs/mint.json index 0040e739b..a25a70124 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -118,7 +118,13 @@ "documentation/platform/pki/alerting" ] }, - "documentation/platform/ssh", + { + "group": "Infisical SSH", + "pages": [ + "documentation/platform/ssh/overview", + "documentation/platform/ssh/host-groups" + ] + }, { "group": "Key Management (KMS)", "pages": [ @@ -949,12 +955,28 @@ { "group": "Identity Specific Privilege", "pages": [ - "api-reference/endpoints/identity-specific-privilege/create-permanent", - "api-reference/endpoints/identity-specific-privilege/create-temporary", - "api-reference/endpoints/identity-specific-privilege/update", - "api-reference/endpoints/identity-specific-privilege/delete", - "api-reference/endpoints/identity-specific-privilege/find-by-slug", - "api-reference/endpoints/identity-specific-privilege/list" + { + "group": "V1 (Legacy)", + "pages": [ + "api-reference/endpoints/identity-specific-privilege/v1/create-permanent", + "api-reference/endpoints/identity-specific-privilege/v1/create-temporary", + "api-reference/endpoints/identity-specific-privilege/v1/update", + "api-reference/endpoints/identity-specific-privilege/v1/delete", + "api-reference/endpoints/identity-specific-privilege/v1/find-by-slug", + "api-reference/endpoints/identity-specific-privilege/v1/list" + ] + }, + { + "group": "V2", + "pages": [ + "api-reference/endpoints/identity-specific-privilege/v2/create", + "api-reference/endpoints/identity-specific-privilege/v2/update", + "api-reference/endpoints/identity-specific-privilege/v2/delete", + "api-reference/endpoints/identity-specific-privilege/v2/list", + "api-reference/endpoints/identity-specific-privilege/v2/find-by-id", + "api-reference/endpoints/identity-specific-privilege/v2/find-by-slug" + ] + } ] }, { @@ -1471,6 +1493,34 @@ { "group": "Infisical SSH", "pages": [ + { + "group": "Hosts", + "pages": [ + "api-reference/endpoints/ssh/hosts/list-my", + "api-reference/endpoints/ssh/hosts/list", + "api-reference/endpoints/ssh/hosts/create", + "api-reference/endpoints/ssh/hosts/read", + "api-reference/endpoints/ssh/hosts/update", + "api-reference/endpoints/ssh/hosts/delete", + "api-reference/endpoints/ssh/hosts/issue-host-cert", + "api-reference/endpoints/ssh/hosts/issue-user-cert", + "api-reference/endpoints/ssh/hosts/read-user-ca-pk", + "api-reference/endpoints/ssh/hosts/read-host-ca-pk" + ] + }, + { + "group": "Host Groups", + "pages": [ + "api-reference/endpoints/ssh/groups/list", + "api-reference/endpoints/ssh/groups/create", + "api-reference/endpoints/ssh/groups/read", + "api-reference/endpoints/ssh/groups/update", + "api-reference/endpoints/ssh/groups/delete", + "api-reference/endpoints/ssh/groups/add-host", + "api-reference/endpoints/ssh/groups/list-hosts", + "api-reference/endpoints/ssh/groups/remove-host" + ] + }, { "group": "Certificates", "pages": [ diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index f44004d78..5efe7ca69 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -304,6 +304,10 @@ export const ROUTE_PATHS = Object.freeze({ SshCaByIDPage: setRoute( "/ssh/$projectId/ca/$caId", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId" + ), + SshHostGroupDetailsByIDPage: setRoute( + "/ssh/$projectId/ssh-host-groups/$sshHostGroupId", + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId" ) }, Public: { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index f7ba69e17..71193dd6e 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -175,6 +175,7 @@ export enum ProjectPermissionSub { SshCertificateTemplates = "ssh-certificate-templates", SshCertificates = "ssh-certificates", SshHosts = "ssh-hosts", + SshHostGroups = "ssh-host-groups", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -272,6 +273,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 32610c28d..cbd20b643 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -107,6 +107,7 @@ export type CompleteAccountSignupDTO = CompleteAccountDTO & { providerAuthToken?: string; attributionSource?: string; organizationName: string; + useDefaultOrg?: boolean; }; export type VerifySignupInviteDTO = { diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 2a80c6174..4bc06f7e3 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -44,6 +44,7 @@ export * from "./serviceTokens"; export * from "./sshCa"; export * from "./sshCertificateTemplates"; export * from "./sshHost"; +export * from "./sshHostGroup"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index ebeb5130a..e92ddeaa8 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -1,3 +1,16 @@ +export enum LoginMappingSource { + HOST = "host", + HOST_GROUP = "hostGroup" +} + +export type TLoginMapping = { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + source: LoginMappingSource; +}; + export type TSshHost = { id: string; projectId: string; @@ -5,26 +18,15 @@ export type TSshHost = { alias: string | null; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; }; - export type TCreateSshHostDTO = { projectId: string; hostname: string; alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: Omit[]; }; export type TUpdateSshHostDTO = { @@ -33,12 +35,7 @@ export type TUpdateSshHostDTO = { alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: Omit[]; }; export type TDeleteSshHostDTO = { diff --git a/frontend/src/hooks/api/sshHostGroup/index.tsx b/frontend/src/hooks/api/sshHostGroup/index.tsx new file mode 100644 index 000000000..b131e1611 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/index.tsx @@ -0,0 +1,8 @@ +export { + useAddHostToSshHostGroup, + useCreateSshHostGroup, + useDeleteSshHostGroup, + useRemoveHostFromSshHostGroup, + useUpdateSshHostGroup +} from "./mutations"; +export { useGetSshHostGroupById, useListSshHostGroupHosts } from "./queries"; diff --git a/frontend/src/hooks/api/sshHostGroup/mutations.tsx b/frontend/src/hooks/api/sshHostGroup/mutations.tsx new file mode 100644 index 000000000..b75cff187 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/mutations.tsx @@ -0,0 +1,105 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { sshHostGroupKeys } from "./queries"; +import { + TCreateSshHostGroupDTO, + TDeleteSshHostGroupDTO, + TSshHostGroup, + TUpdateSshHostGroupDTO +} from "./types"; + +export const useCreateSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: hostGroup } = await apiRequest.post("/api/v1/ssh/host-groups", body); + return hostGroup; + }, + onSuccess: ({ projectId, id }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(id) + }); + } + }); +}; + +export const useUpdateSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId, ...body }) => { + const { data: hostGroup } = await apiRequest.patch( + `/api/v1/ssh/host-groups/${sshHostGroupId}`, + body + ); + return hostGroup; + }, + onSuccess: ({ projectId }, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) + }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) + }); + } + }); +}; + +export const useDeleteSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId }) => { + const { data: hostGroup } = await apiRequest.delete( + `/api/v1/ssh/host-groups/${sshHostGroupId}` + ); + return hostGroup; + }, + onSuccess: ({ projectId }, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) + }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) + }); + } + }); +}; + +export const useAddHostToSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId, sshHostId }) => { + await apiRequest.post(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`); + }, + onSuccess: (_, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId) + }); + } + }); +}; + +export const useRemoveHostFromSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId, sshHostId }) => { + await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`); + }, + onSuccess: (_, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/queries.tsx b/frontend/src/hooks/api/sshHostGroup/queries.tsx new file mode 100644 index 000000000..8be42dd7b --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/queries.tsx @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { EHostGroupMembershipFilter, TListSshHostGroupHostsResponse, TSshHostGroup } from "./types"; + +export const sshHostGroupKeys = { + getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"], + allSshHostGroupHosts: () => ["ssh-host-group-hosts"] as const, + forSshHostGroupHosts: (sshHostGroupId: string) => + [...sshHostGroupKeys.allSshHostGroupHosts(), sshHostGroupId] as const, + specificSshHostGroupHosts: ({ + sshHostGroupId, + filter + }: { + sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; + }) => [...sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId), { filter }] as const +}; + +export const useGetSshHostGroupById = (sshHostGroupId: string) => { + return useQuery({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId), + queryFn: async () => { + const { data: sshHostGroup } = await apiRequest.get( + `/api/v1/ssh/host-groups/${sshHostGroupId}` + ); + return sshHostGroup; + }, + enabled: Boolean(sshHostGroupId) + }); +}; + +export const useListSshHostGroupHosts = ({ + sshHostGroupId, + filter +}: { + sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; +}) => { + return useQuery({ + queryKey: sshHostGroupKeys.specificSshHostGroupHosts({ sshHostGroupId, filter }), + queryFn: async () => { + const params = new URLSearchParams({ + ...(filter ? { filter } : {}) + }); + + const { data } = await apiRequest.get( + `/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`, + { + params + } + ); + return data; + }, + enabled: Boolean(sshHostGroupId), + staleTime: 0, + gcTime: 0 + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/types.ts b/frontend/src/hooks/api/sshHostGroup/types.ts new file mode 100644 index 000000000..6e193c8b6 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/types.ts @@ -0,0 +1,34 @@ +import { TLoginMapping, TSshHost } from "../sshHost/types"; + +export type TSshHostGroup = { + id: string; + projectId: string; + name: string; + loginMappings: Omit[]; +}; + +export type TCreateSshHostGroupDTO = { + projectId: string; + name: string; + loginMappings: Omit[]; +}; + +export type TUpdateSshHostGroupDTO = { + sshHostGroupId: string; + name?: string; + loginMappings?: Omit[]; +}; + +export type TDeleteSshHostGroupDTO = { + sshHostGroupId: string; +}; + +export type TListSshHostGroupHostsResponse = { + hosts: (TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean })[]; + totalCount: number; +}; + +export enum EHostGroupMembershipFilter { + GROUP_MEMBERS = "group-members", + NON_GROUP_MEMBERS = "non-group-members" +} diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ab277ddc8..ec7b6a2dd 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -24,6 +24,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + sshHostGroups: boolean; secretAccessInsights: boolean; hsm: boolean; oidcSSO: boolean; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index a7b402d3c..b841f4bff 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -38,6 +38,7 @@ export { useListWorkspaceSshCas, useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, + useListWorkspaceSshHostGroups, useListWorkspaceSshHosts, useNameWorkspaceSecrets, useSearchProjects, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index c869bd66f..441b9aefa 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -18,6 +18,7 @@ import { EncryptedSecret } from "../secrets/types"; import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types"; import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { TSshHost } from "../sshHost/types"; +import { TSshHostGroup } from "../sshHostGroup/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { @@ -873,6 +874,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => { }); }; +export const useListWorkspaceSshHostGroups = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId), + queryFn: async () => { + const { + data: { groups } + } = await apiRequest.get<{ groups: (TSshHostGroup & { hostCount: number })[] }>( + `/api/v2/workspace/${projectId}/ssh-host-groups` + ); + return groups; + }, + enabled: Boolean(projectId) + }); +}; + export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index fb2d30ce8..91fe95a04 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -63,6 +63,8 @@ export const workspaceKeys = { allWorkspaceSshCertificates: (projectId: string) => [{ projectId }, "workspace-ssh-certificates"] as const, getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const, + getWorkspaceSshHostGroups: (projectId: string) => + [{ projectId }, "workspace-ssh-host-groups"] as const, specificWorkspaceSshCertificates: ({ offset, limit, diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 1f1f0760c..790adff50 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -238,7 +238,7 @@ export const OverviewPage = () => { Default organization
- Select the default organization you want to set for SAML/LDAP/OIDC based + Select the default organization you want to set for SAML/LDAP/OIDC/Github logins. When selected, user logins will be automatically scoped to the selected organization.
diff --git a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx index 40075ae54..6cb4da82b 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx @@ -13,6 +13,7 @@ export const SignupSsoPage = () => { const { t } = useTranslation(); const search = useSearch({ from: ROUTE_PATHS.Auth.SignUpSsoPage.id }); const token = search.token as string; + const defaultOrgAllowed = search.defaultOrgAllowed as boolean | undefined; const [step, setStep] = useState(0); const [password, setPassword] = useState(""); @@ -57,6 +58,7 @@ export const SignupSsoPage = () => { password={password} setPassword={setPassword} providerAuthToken={token} + forceDefaultOrg={defaultOrgAllowed} /> ); default: diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx index a6ab0623b..2f2ade8e7 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -30,6 +30,7 @@ type Props = { name: string; providerOrganizationName: string; providerAuthToken?: string; + forceDefaultOrg?: boolean; }; /** @@ -51,7 +52,8 @@ export const UserInfoSSOStep = ({ providerOrganizationName, password, setPassword, - providerAuthToken + providerAuthToken, + forceDefaultOrg }: Props) => { const [nameError, setNameError] = useState(false); const [organizationName, setOrganizationName] = useState(""); @@ -84,7 +86,7 @@ export const UserInfoSSOStep = ({ } else { setNameError(false); } - if (!organizationName) { + if (!organizationName && !forceDefaultOrg) { setOrganizationNameError(true); errorCheck = true; } else { @@ -160,7 +162,8 @@ export const UserInfoSSOStep = ({ salt: result.salt, verifier: result.verifier, organizationName, - attributionSource + attributionSource, + useDefaultOrg: forceDefaultOrg }); // unset signup JWT token and set JWT token @@ -267,7 +270,7 @@ export const UserInfoSSOStep = ({

)} - {providerOrganizationName === undefined && ( + {!forceDefaultOrg && providerOrganizationName === undefined && (

Organization Name @@ -279,7 +282,7 @@ export const UserInfoSSOStep = ({ isRequired className="h-12" maxLength={64} - disabled + isDisabled={forceDefaultOrg} /> {organizationNameError && (

diff --git a/frontend/src/pages/auth/SignUpSsoPage/route.tsx b/frontend/src/pages/auth/SignUpSsoPage/route.tsx index a255efaf6..986735ad0 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/route.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/route.tsx @@ -5,7 +5,8 @@ import { z } from "zod"; import { SignupSsoPage } from "./SignUpSsoPage"; const SignupSSOPageQueryParamsSchema = z.object({ - token: z.string() + token: z.string(), + defaultOrgAllowed: z.boolean().optional() }); export const Route = createFileRoute("/_restrict-login-signup/signup/sso")({ diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index de2295940..3a1bdb1d2 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -234,6 +234,7 @@ export const projectRoleFormSchema = z.object({ }) .array() .default([]), + [ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), @@ -380,7 +381,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.Kms, ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateAuthorities, - ProjectPermissionSub.SshCertificates + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshHostGroups ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -1064,6 +1066,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert } ] }, + [ProjectPermissionSub.SshHostGroups]: { + title: "SSH Host Groups", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, [ProjectPermissionSub.PkiCollections]: { title: "PKI Collections", actions: [ diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx new file mode 100644 index 000000000..c5f13d792 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx @@ -0,0 +1,157 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + PageHeader, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteSshHostGroup, useGetSshHostGroupById } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshHostGroupModal } from "../SshHostsPage/components/SshHostGroupModal"; +import { SshHostGroupDetailsSection, SshHostGroupHostsSection } from "./components"; + +const Page = () => { + const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate(); + const projectId = currentWorkspace?.id || ""; + const sshHostGroupId = useParams({ + from: ROUTE_PATHS.Ssh.SshHostGroupDetailsByIDPage.id, + select: (el) => el.sshHostGroupId + }); + const { data } = useGetSshHostGroupById(sshHostGroupId); + + const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshHostGroup", + "deleteSshHostGroup" + ] as const); + + const onRemoveSshGroupSubmit = async (groupIdToDelete: string) => { + try { + if (!projectId) return; + + await deleteSshHostGroup({ sshHostGroupId: groupIdToDelete }); + + createNotification({ + text: "Successfully deleted SSH group", + type: "success" + }); + + handlePopUpClose("deleteSshHostGroup"); + navigate({ + to: `/${ProjectType.SSH}/$projectId/overview` as const, + params: { + projectId + } + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH group", + type: "error" + }); + } + }; + + return ( +

+ {data && ( +
+ + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("deleteSshHostGroup", { + groupId: data.id, + name: data.name + }) + } + disabled={!isAllowed} + > + Delete SSH Group + + )} + + +
+
+
+
+ +
+
+ +
+
+
+ )} + + handlePopUpToggle("deleteSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId) + } + /> +
+ ); +}; + +export const SshHostGroupDetailsByIDPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "SSH Group" })} + + + + + + ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx new file mode 100644 index 000000000..2374492a5 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx @@ -0,0 +1,127 @@ +import { faServer } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useAddHostToSshHostGroup, useListSshHostGroupHosts } from "@app/hooks/api"; +import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["addHostGroupMembers"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["addHostGroupMembers"]>, + state?: boolean + ) => void; +}; + +export const AddHostGroupMemberModal = ({ popUp, handlePopUpToggle }: Props) => { + const popUpData = popUp?.addHostGroupMembers?.data as { + sshHostGroupId: string; + }; + + const { data, isPending } = useListSshHostGroupHosts({ + sshHostGroupId: popUpData?.sshHostGroupId, + filter: EHostGroupMembershipFilter.NON_GROUP_MEMBERS + }); + const { mutateAsync: addHostToSshHostGroup, isPending: isAddingHostToSshHostGroup } = + useAddHostToSshHostGroup(); + + const handleAddHost = async (sshHostId: string) => { + try { + if (!popUpData?.sshHostGroupId) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addHostToSshHostGroup({ + sshHostGroupId: popUpData.sshHostGroupId, + sshHostId + }); + + createNotification({ + text: "Successfully added host to the group", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to add host to the group", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addHostGroupMembers", isOpen); + }} + > + + + + + + + + + + + {isPending && } + {!isPending && + data?.hosts?.map((host) => { + return ( + + + + + + ); + })} + +
AliasHostname +
{host.alias ?? "-"}{host.hostname} + + {(isAllowed) => ( + + )} + +
+ {!isPending && !data?.hosts?.length && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx new file mode 100644 index 000000000..80f1f8d46 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx @@ -0,0 +1,84 @@ +import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetSshHostGroupById } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + sshHostGroupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, data?: object) => void; +}; + +export const SshHostGroupDetailsSection = ({ sshHostGroupId, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId); + + return sshHostGroup ? ( +
+
+

SSH Host Group Details

+ + {(isAllowed) => { + return ( + + { + e.stopPropagation(); + handlePopUpOpen("sshHostGroup", { + sshHostGroupId: sshHostGroup.id + }); + }} + > + + + + ); + }} + +
+
+
+

SSH Host Group ID

+
+

{sshHostGroup.id}

+
+ + { + navigator.clipboard.writeText(sshHostGroup.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Name

+

{sshHostGroup.name}

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx new file mode 100644 index 000000000..88a7eebae --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx @@ -0,0 +1,113 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useRemoveHostFromSshHostGroup } from "@app/hooks/api"; + +import { AddHostGroupMemberModal } from "./AddHostGroupMemberModal"; +import { SshHostGroupHostsTable } from "./SshHostGroupHostsTable"; + +type Props = { + sshHostGroupId: string; +}; + +export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeHostFromSshHostGroup", + "addHostGroupMembers", + "upgradePlan" + ] as const); + + const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup(); + + const handleAddSshHostModal = () => { + if (!subscription?.sshHostGroups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license." + }); + } else { + handlePopUpOpen("addHostGroupMembers", { + sshHostGroupId + }); + } + }; + + const onRemoveSshHostSubmit = async (sshHostId: string) => { + try { + await removeHostFromGroup({ + sshHostId, + sshHostGroupId + }); + + await createNotification({ + text: "Successfully removed host from SSH group", + type: "success" + }); + + handlePopUpClose("removeHostFromSshHostGroup"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove host from SSH group", + type: "error" + }); + } + }; + + return ( +
+
+

SSH Hosts

+ + {(isAllowed) => ( + handleAddSshHostModal()} + isDisabled={!isAllowed} + > + + + )} + +
+
+ +
+ + handlePopUpToggle("removeHostFromSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshHostSubmit( + (popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx new file mode 100644 index 000000000..7866d2125 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx @@ -0,0 +1,97 @@ +import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useListSshHostGroupHosts } from "@app/hooks/api"; +import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + sshHostGroupId: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeHostFromSshHostGroup"]>, + data?: object + ) => void; +}; + +export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Props) => { + const { data, isPending } = useListSshHostGroupHosts({ + sshHostGroupId, + filter: EHostGroupMembershipFilter.GROUP_MEMBERS + }); + + return ( +
+ + + + + + + + + + + {isPending && } + {!isPending && + data?.hosts.map((host) => { + return ( + + + + + + + ); + })} + +
AliasHostnameAdded On +
{host.alias ?? "-"}{host.hostname}{new Date(host.joinedGroupAt).toLocaleDateString()} + + {(isAllowed) => ( + + + handlePopUpOpen("removeHostFromSshHostGroup", { + sshHostId: host.id, + alias: host.alias, + hostname: host.hostname + }) + } + variant="plain" + colorSchema="danger" + > + + + + )} + +
+ {!isPending && !data?.hosts?.length && ( + + )} +
+
+ ); +}; + +export default SshHostGroupHostsTable; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx new file mode 100644 index 000000000..ff25edf77 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx @@ -0,0 +1,3 @@ +export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection"; +export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection"; +export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable"; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx new file mode 100644 index 000000000..2a7c1cde6 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SshHostGroupDetailsByIDPage } from "./SshHostGroupDetailsByIDPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId" +)({ + component: SshHostGroupDetailsByIDPage +}); diff --git a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx index aca76bb73..c6efa9670 100644 --- a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; -import { SshHostsSection } from "./components"; +import { SshHostGroupsSection, SshHostsSection } from "./components"; export const SshHostsPage = () => { const { t } = useTranslation(); @@ -19,6 +19,7 @@ export const SshHostsPage = () => { title="Hosts" description="Manage your SSH hosts, configure access policies, and define login behavior for secure connections." /> +
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx new file mode 100644 index 000000000..de5d82311 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx @@ -0,0 +1,396 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateSshHostGroup, + useGetSshHostGroupById, + useGetWorkspaceUsers, + useListWorkspaceSshHostGroups, + useUpdateSshHostGroup +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["sshHostGroup"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, state?: boolean) => void; +}; + +const schema = z + .object({ + name: z.string().trim().min(1).max(64), + loginMappings: z + .object({ + loginUser: z.string().trim().min(1), + allowedPrincipals: z.array(z.string().trim()).default([]) + }) + .array() + .default([]) + }) + .required(); + +export type FormData = z.infer; + +export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id); + const { data: members = [] } = useGetWorkspaceUsers(projectId); + const [expandedMappings, setExpandedMappings] = useState>({}); + + const { data: sshHostGroup } = useGetSshHostGroupById( + (popUp?.sshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId || "" + ); + + const { mutateAsync: createMutateAsync } = useCreateSshHostGroup(); + const { mutateAsync: updateMutateAsync } = useUpdateSshHostGroup(); + + const { + control, + handleSubmit, + reset, + getValues, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + loginMappings: [] + } + }); + + const loginMappingsFormFields = useFieldArray({ + control, + name: "loginMappings" + }); + + useEffect(() => { + if (sshHostGroup) { + reset({ + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginUser, + allowedPrincipals: allowedPrincipals.usernames + })) + }); + + setExpandedMappings( + Object.fromEntries(sshHostGroup.loginMappings.map((_, index) => [index, false])) + ); + } else { + reset({ + name: "", + loginMappings: [] + }); + } + }, [sshHostGroup]); + + const onFormSubmit = async ({ name, loginMappings }: FormData) => { + try { + if (!projectId) return; + + // check if there is already a different host group with the same name + const existingNames = + sshHostGroups?.filter((h) => h.id !== sshHostGroup?.id).map((h) => h.name) || []; + + if (existingNames.includes(name.trim())) { + createNotification({ + text: "A host group with this name already exists.", + type: "error" + }); + return; + } + + if (sshHostGroup) { + await updateMutateAsync({ + sshHostGroupId: sshHostGroup.id, + name, + loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginUser, + allowedPrincipals: { + usernames: allowedPrincipals + } + })) + }); + } else { + await createMutateAsync({ + projectId, + name, + loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginUser, + allowedPrincipals: { + usernames: allowedPrincipals + } + })) + }); + } + + reset(); + handlePopUpToggle("sshHostGroup", false); + + createNotification({ + text: `Successfully ${sshHostGroup ? "updated" : "created"} SSH host group`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${sshHostGroup ? "update" : "create"} SSH host group`, + type: "error" + }); + } + }; + + const toggleMapping = (index: number) => { + setExpandedMappings((prev) => ({ + ...prev, + [index]: !prev[index] + })); + }; + + return ( + { + reset(); + handlePopUpToggle("sshHostGroup", isOpen); + }} + > + +
+ {sshHostGroup && ( + + + + )} + ( + + + + )} + /> +
+ + +
+
+ {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}} +
+ )} + /> +
+ + )} +
+ ))} +
+
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx new file mode 100644 index 000000000..70036d88a --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx @@ -0,0 +1,96 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { useDeleteSshHostGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshHostGroupModal } from "./SshHostGroupModal"; +import { SshHostGroupsTable } from "./SshHostGroupsTable"; + +export const SshHostGroupsSection = () => { + const { subscription } = useSubscription(); + const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshHostGroup", + "deleteSshHostGroup", + "upgradePlan" + ] as const); + + const handleAddSshHostGroupModal = () => { + if (!subscription?.sshHostGroups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license." + }); + } else { + handlePopUpOpen("sshHostGroup"); + } + }; + + const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => { + try { + const hostGroup = await deleteSshHostGroup({ sshHostGroupId }); + + createNotification({ + text: `Successfully deleted SSH host group: ${hostGroup.name}`, + type: "success" + }); + + handlePopUpClose("deleteSshHostGroup"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH host group", + type: "error" + }); + } + }; + + return ( +
+
+

Host Groups

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshHostGroupSubmit( + (popUp?.deleteSshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx new file mode 100644 index 000000000..194d9e90e --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -0,0 +1,184 @@ +import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceSshHostGroups } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSshHostGroup", "sshHostGroup"]>, + data?: object + ) => void; +}; + +export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { + const navigate = useNavigate(); + const { currentWorkspace } = useWorkspace(); + const { data, isPending } = useListWorkspaceSshHostGroups(currentWorkspace?.id || ""); + return ( +
+ + + + + + + + + + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map((group) => { + return ( + + navigate({ + to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const, + params: { + projectId: currentWorkspace.id, + sshHostGroupId: group.id + } + }) + } + > + + + + + + ); + })} + +
Name# Hosts in GroupLogin User - Authorized Principals Mapping +
{group.name}{group.hostCount} + {group.loginMappings.length === 0 ? ( + None + ) : ( + group.loginMappings.map(({ loginUser, allowedPrincipals }) => ( +
+
{loginUser}
+ {allowedPrincipals.usernames.map((username) => ( +
+ └─ {username} +
+ ))} +
+ )) + )} +
+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("sshHostGroup", { + sshHostGroupId: group.id + }); + }} + disabled={!isAllowed} + icon={} + > + Edit Host Group + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + navigate({ + to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const, + params: { + projectId: currentWorkspace.id, + sshHostGroupId: group.id + } + }); + }} + disabled={!isAllowed} + icon={} + > + Manage Hosts + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteSshHostGroup", { + sshHostGroupId: group.id, + name: group.name + }); + }} + disabled={!isAllowed} + icon={} + > + Delete Host Group + + )} + + +
+
+ {!isPending && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index ede0a35a0..5ac53a16e 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -26,6 +26,7 @@ import { useListWorkspaceSshHosts, useUpdateSshHost } from "@app/hooks/api"; +import { LoginMappingSource } from "@app/hooks/api/sshHost/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -48,7 +49,8 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.array(z.string().trim()).default([]) + allowedPrincipals: z.array(z.string().trim()).default([]), + source: z.nativeEnum(LoginMappingSource) }) .array() .default([]) @@ -99,9 +101,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname: sshHost.hostname, alias: sshHost.alias ?? "", userCertTtl: sshHost.userCertTtl, - loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.usernames + allowedPrincipals: allowedPrincipals.usernames, + source })) }); @@ -122,6 +125,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { try { if (!projectId) return; + // Filter out login mappings that are from host groups + const hostLoginMappings = loginMappings.filter( + (mapping) => mapping.source === LoginMappingSource.HOST + ); + // check if there is already a different host with the same hostname const existingHostnames = sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || []; @@ -157,7 +165,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, allowedPrincipals: { usernames: allowedPrincipals @@ -170,7 +178,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, allowedPrincipals: { usernames: allowedPrincipals @@ -265,7 +273,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { variant="outline_bg" onClick={() => { const newIndex = loginMappingsFormFields.fields.length; - loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] }); + loginMappingsFormFields.append({ + loginUser: "", + allowedPrincipals: [""], + source: LoginMappingSource.HOST + }); setExpandedMappings((prev) => ({ ...prev, [newIndex]: true @@ -298,6 +310,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { render={({ field }) => ( {field.value || "New Login Mapping"} + {loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP && ( + + (inherited from host group) + + )} )} /> @@ -306,6 +324,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { ariaLabel="delete login mapping" variant="plain" onClick={() => loginMappingsFormFields.remove(i)} + isDisabled={ + loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST_GROUP + } > @@ -327,11 +348,24 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { { + if ( + loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP + ) + return; + const newValue = e.target.value; const loginMappings = getValues("loginMappings"); const isDuplicate = loginMappings.some( - (mapping, index) => index !== i && mapping.loginUser === newValue + (mapping, index) => + index !== i && + mapping.loginUser === newValue && + mapping.source === LoginMappingSource.HOST ); if (isDuplicate) { @@ -355,17 +389,20 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { label="Allowed Principals" className="text-xs text-mineshaft-400" /> - + {loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && ( + + )} {