diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 34d816b9b..4b2ccae25 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"; @@ -213,6 +214,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 091199938..8bc23bc5c 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, @@ -445,6 +451,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..9f61ddb6c --- /dev/null +++ b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts @@ -0,0 +1,57 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +// TODO: can attach default SSH login mappings to a host group +// TODO: can attach default user SSH CA and host SSH CA (convert existing project level ones to a group) + +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.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 7ccd71376..c77a617b9 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -127,6 +127,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 dd23c26da..fc430c528 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/organizations.ts b/backend/src/db/schemas/organizations.ts index 902c564a7..eea1808e0 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), - secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 2403d6cf4..297601fd0 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({ description: z.string().nullable().optional(), type: z.string(), enforceCapitalization: z.boolean().default(false), - hasDeleteProtection: z.boolean().default(true).nullable().optional() + hasDeleteProtection: z.boolean().default(false).nullable().optional() }); export type TProjects = z.infer; 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..0e7026c90 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-host-group-router.ts @@ -0,0 +1,351 @@ +import { z } from "zod"; + +import { sanitizedSshHost, loginMappingSchema } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; +import { 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: { + 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: host.projectId, + // event: { + // type: EventType.GET_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname + // } + // } + // }); + + return sshHostGroup; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create SSH Host Group", + body: z.object({ + projectId: z.string().describe(SSH_HOST_GROUPS.CREATE.projectId), + name: slugSchema({ min: 0, 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 + }); + + // TODO: audit logs + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: host.projectId, + // event: { + // type: EventType.CREATE_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname, + // alias: host.alias ?? null, + // userCertTtl: host.userCertTtl, + // hostCertTtl: host.hostCertTtl, + // loginMappings: host.loginMappings, + // userSshCaId: host.userSshCaId, + // hostSshCaId: host.hostSshCaId + // } + // } + // }); + + return sshHostGroup; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshHostGroupId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH Host", + params: z.object({ + sshHostGroupId: z.string().trim().describe(SSH_HOST_GROUPS.UPDATE.sshHostGroupId) + }), + body: z.object({ + name: slugSchema({ min: 0, 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: host.projectId, + // event: { + // type: EventType.UPDATE_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname, + // alias: host.alias, + // userCertTtl: host.userCertTtl, + // hostCertTtl: host.hostCertTtl, + // loginMappings: host.loginMappings, + // userSshCaId: host.userSshCaId, + // hostSshCaId: host.hostSshCaId + // } + // } + // }); + + return sshHostGroup; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostGroupId", + config: { + rateLimit: writeLimit + }, + schema: { + 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 + }); + + // TODO: audit log + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: host.projectId, + // event: { + // type: EventType.DELETE_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname + // } + // } + // }); + + return sshHostGroup; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostGroupId/hosts", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0).describe(SSH_HOST_GROUPS.LIST_HOSTS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(SSH_HOST_GROUPS.LIST_HOSTS.limit) + }), + response: { + 200: z.object({ + hosts: z.array(sanitizedSshHost), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + console.log("list hosts in group pre"); + const { 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 + }); + console.log("list hosts in group post"); + + // TODO: audit logs + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: host.projectId, + // event: { + // type: EventType.GET_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname + // } + // } + // }); + + return { hosts, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostGroupId/hosts/:hostId", + config: { + rateLimit: writeLimit + }, + schema: { + 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) => { + console.log("add host to group pre"); + + const host = 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 + }); + + console.log("add host to group post"); + + // TODO: audit logs + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: host.projectId, + // event: { + // type: EventType.GET_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname + // } + // } + // }); + + return host; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostGroupId/hosts/:hostId", + config: { + rateLimit: writeLimit + }, + schema: { + 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) => { + console.log("remove host from group pre"); + + const host = 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 + }); + + console.log("remove host from group post"); + + // TODO: audit logs + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: host.projectId, + // event: { + // type: EventType.GET_SSH_HOST, + // metadata: { + // sshHostId: host.id, + // hostname: host.hostname + // } + // } + // }); + + return host; + } + }); +}; 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..ca8fbcbc4 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -22,7 +22,7 @@ export const getDefaultOnPremFeatures = () => { samlSSO: false, scim: false, ldap: false, - groups: false, + groups: true, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 548f6e82b..2870373c7 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -35,7 +35,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ oidcSSO: false, scim: false, ldap: false, - groups: false, + groups: true, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 6f0d82344..923ef2eba 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -52,7 +52,7 @@ export type TFeatureSet = { secretAccessInsights: false; scim: false; ldap: false; - groups: false; + groups: true; status: null; trial_end: null; has_used_trial: true; 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..e9e652591 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts @@ -0,0 +1,166 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { ormify } from "@app/lib/knex"; + +export type TSshHostGroupDALFactory = ReturnType; + +export const sshHostGroupDALFactory = (db: TDbClient) => { + const sshHostGroupOrm = ormify(db, TableName.SshHostGroup); + + const findSshHostGroupsWithLoginMappings = async (projectId: 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}.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); + + 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 + }; + }); + } 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 + }: { + sshHostGroupId: string; + offset?: number; + limit?: number; + }) => { + try { + const query = db + .replicaNode()(TableName.SshHostGroupMembership) + .where(`${TableName.SshHostGroupMembership}.sshHostGroupId`, sshHostGroupId) + .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .select( + db.ref("id").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("alias").withSchema(TableName.SshHost), + 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); + } + + const hosts = await query; + + return { + hosts: hosts.map(({ id, hostname, alias }) => ({ + id, + hostname, + alias + })), + // @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..4627e2e27 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -0,0 +1,353 @@ +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 { NotFoundError } from "@app/lib/errors"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; +import { + TAddHostToSshHostGroupDTO, + TCreateSshHostGroupDTO, + TDeleteSshHostGroupDTO, + TGetSshHostGroupDTO, + TListSshHostGroupHostsDTO, + TRemoveHostFromSshHostGroupDTO, + TUpdateSshHostGroupDTO +} from "./ssh-host-group-types"; + +type TSshHostGroupServiceFactoryDep = { + sshHostDAL: TSshHostDALFactory; // TODO: Pick + sshHostGroupDAL: Pick< + TSshHostGroupDALFactory, + | "create" + | "updateById" + | "findById" + | "deleteById" + | "transaction" + | "findSshHostGroupByIdWithLoginMappings" + | "findAllSshHostsInGroup" + >; + sshHostGroupMembershipDAL: TSshHostGroupMembershipDALFactory; // TODO: Pick + sshHostLoginUserDAL: Pick; + sshHostLoginUserMappingDAL: Pick; + userDAL: Pick; + permissionService: Pick; +}; + +export type TSshHostGroupServiceFactory = ReturnType; + +export const sshHostGroupServiceFactory = ({ + sshHostDAL, + sshHostGroupDAL, + sshHostGroupMembershipDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService +}: 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 newSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { + const sshHostGroup = await sshHostGroupDAL.create( + { + projectId, + name // TODO: check that this is unique across the whole org + }, + 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 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 + }: 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); + + // TODO: check + const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId }); + console.log("hosts: ", hosts); + return { 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 host = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found` + }); + } + + if (sshHostGroup.projectId !== host.projectId) { + throw new NotFoundError({ + 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.Read, ProjectPermissionSub.SshHostGroups); + // TODO: look over permissioning + + await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId }); + + return host; + }; + + 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 host = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!host) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found` + }); + } + + if (sshHostGroup.projectId !== host.projectId) { + throw new NotFoundError({ + 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); + // TODO: look over permissioning + + 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 host; + }; + + 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..6bc895a73 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts @@ -0,0 +1,43 @@ +import { TGenericPermission, TProjectPermission } from "@app/lib/types"; + +export type TCreateSshHostGroupDTO = { + name: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +} & TProjectPermission; + +export type TUpdateSshHostGroupDTO = { + sshHostGroupId: string; + name?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +} & TGenericPermission; + +export type TGetSshHostGroupDTO = { + sshHostGroupId: string; +} & TGenericPermission; + +export type TDeleteSshHostGroupDTO = { + sshHostGroupId: string; +} & TGenericPermission; +export type TListSshHostGroupHostsDTO = { + sshHostGroupId: string; +} & TGenericPermission; + +export type TAddHostToSshHostGroupDTO = { + sshHostGroupId: string; + hostId: string; +} & TGenericPermission; + +export type TRemoveHostFromSshHostGroupDTO = { + sshHostGroupId: string; + hostId: string; +} & TGenericPermission; 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..08da764fc 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,27 @@ +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; +type LoginMapping = { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; +}; + export type TCreateSshHostDTO = { hostname: string; alias?: string; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: LoginMapping[]; userSshCaId?: string; hostSshCaId?: string; } & TProjectPermission; @@ -23,12 +32,7 @@ export type TUpdateSshHostDTO = { alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: LoginMapping[]; } & Omit; export type TGetSshHostDTO = { @@ -48,3 +52,19 @@ export type TIssueSshHostHostCertDTO = { sshHostId: string; publicKey: string; } & Omit; + +type BaseCreateSshLoginMappingsDTO = { + loginMappings: LoginMapping[]; + 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 19ee7e331..e861900d0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -568,6 +568,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 +1385,39 @@ export const SSH_CERTIFICATE_TEMPLATES = { } }; +export const SSH_HOST_GROUPS = { + GET: { + sshHostGroupId: "The ID of the SSH host group to get." + }, + 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 8ceeba648..a53b24ec8 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"; @@ -399,6 +402,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); @@ -849,6 +854,16 @@ export const registerRoutes = async ( kmsService }); + const sshHostGroupService = sshHostGroupServiceFactory({ + sshHostDAL, + sshHostGroupDAL, + sshHostGroupMembershipDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -1018,6 +1033,7 @@ export const registerRoutes = async ( sshCertificateDAL, sshCertificateTemplateDAL, sshHostDAL, + sshHostGroupDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1668,6 +1684,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/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index f7540591e..d527720a2 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -14,6 +14,7 @@ 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 { 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"; @@ -650,4 +651,38 @@ 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: z.array(loginMappingSchema) + }) + ) + }) + } + }, + 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/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9f2de8a85..0249d005f 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"; @@ -136,12 +137,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; @@ -193,6 +194,7 @@ export const projectServiceFactory = ({ sshCertificateDAL, sshCertificateTemplateDAL, sshHostDAL, + sshHostGroupDAL, keyStore, kmsService, projectBotDAL, @@ -1143,6 +1145,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 */ @@ -1665,6 +1693,7 @@ export const projectServiceFactory = ({ listProjectCertificateTemplates, listProjectSshCas, listProjectSshHosts, + listProjectSshHostGroups, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index cd3c6675f..a74974328 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -298,6 +298,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" + ), + SshGroupDetailsByIDPage: setRoute( + "/ssh/$projectId/ssh-groups/$groupId", + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/groups/$groupId" ) }, 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/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/sshHostGroup/index.tsx b/frontend/src/hooks/api/sshHostGroup/index.tsx new file mode 100644 index 000000000..d78ec3986 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/index.tsx @@ -0,0 +1,2 @@ +export { useCreateSshHostGroup, useDeleteSshHostGroup, useUpdateSshHostGroup } from "./mutations"; +export { useGetSshHostGroupById } 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..7a5b3f168 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/mutations.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +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 }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + } + }); +}; + +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 }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + } + }); +}; + +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 }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/queries.tsx b/frontend/src/hooks/api/sshHostGroup/queries.tsx new file mode 100644 index 000000000..1ba54e445 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshHostGroup } from "./types"; + +export const sshHostGroupKeys = { + getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"] +}; + +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) + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/types.ts b/frontend/src/hooks/api/sshHostGroup/types.ts new file mode 100644 index 000000000..c7d1bf80c --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/types.ts @@ -0,0 +1,37 @@ +export type TSshHostGroup = { + id: string; + projectId: string; + name: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TCreateSshHostGroupDTO = { + projectId: string; + name: string; + loginMappings: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TUpdateSshHostGroupDTO = { + sshHostGroupId: string; + name?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +}; + +export type TDeleteSshHostGroupDTO = { + sshHostGroupId: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c4defb68d..fe09fd7f9 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 0a2bf491b..9b1788c96 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 { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -870,6 +871,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[] }>( + `/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 05e9c7588..e20a5abcc 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -60,6 +60,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/ssh/SshGroupDetailsByIDPage/SshGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshGroupDetailsByIDPage/SshGroupDetailsByIDPage.tsx new file mode 100644 index 000000000..1214058d9 --- /dev/null +++ b/frontend/src/pages/ssh/SshGroupDetailsByIDPage/SshGroupDetailsByIDPage.tsx @@ -0,0 +1,147 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "@tanstack/react-router"; + +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"; + +const Page = () => { + const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate(); + const projectId = currentWorkspace?.id || ""; + const groupId = useParams({ + from: ROUTE_PATHS.Ssh.SshGroupDetailsByIDPage.id, + select: (el) => el.groupId + }); + const { data } = useGetSshHostGroupById(groupId); + + 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 + }) + } + disabled={!isAllowed} + > + Delete SSH Group + + )} + + +
+
+
+
+ +
+
+ +
+
+
+ )} + + handlePopUpToggle("deleteSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId) + } + /> +
+ ); +}; + +export const SshGroupDetailsByIDPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "SSH Group" })} + + + + + + ); +}; diff --git a/frontend/src/pages/ssh/SshGroupDetailsByIDPage/route.tsx b/frontend/src/pages/ssh/SshGroupDetailsByIDPage/route.tsx new file mode 100644 index 000000000..37bd30551 --- /dev/null +++ b/frontend/src/pages/ssh/SshGroupDetailsByIDPage/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { SshGroupDetailsByIDPage } from './SshGroupDetailsByIDPage' + +export const Route = createFileRoute( + '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId', +)({ + component: SshGroupDetailsByIDPage, +}) 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/SshHostGroupHostsModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupHostsModal.tsx new file mode 100644 index 000000000..75c94ac1b --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupHostsModal.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; +import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { useDebounce, useResetPageHelper } from "@app/hooks"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["sshHostGroupHosts"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["sshHostGroupHosts"]>, + state?: boolean + ) => void; +}; + +export const SshHostGroupHostsModal = ({ popUp, handlePopUpToggle }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [searchMemberFilter, setSearchMemberFilter] = useState(""); + const [debouncedSearch] = useDebounce(searchMemberFilter); + + const popUpData = popUp?.addGroupMembers?.data as { + groupId: string; + slug: string; + }; + + const offset = (page - 1) * perPage; + const { data, isPending } = useListGroupUsers({ + id: popUpData?.groupId, + groupSlug: popUpData?.slug, + offset, + limit: perPage, + search: debouncedSearch, + filter: EFilterReturnedUsers.NON_MEMBERS + }); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); + + const handleAddHost = async (hostId: string) => { + try { + if (!popUpData?.slug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + // await addUserToGroupMutateAsync({ + // groupId: popUpData.groupId, + // username, + // slug: popUpData.slug + // }); + + createNotification({ + text: "Successfully assigned host to the SSH host group", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to assign host to the SSH host group", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("sshHostGroupHosts", isOpen); + }} + > + + setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + {isPending && } + {!isPending && + data?.users?.map(({ id, firstName, lastName, username }) => { + return ( + + + + + ); + })} + +
User +
+

{`${firstName ?? "-"} ${lastName ?? ""}`}

+

{username}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.users?.length && ( + + )} +
+
+
+ ); +}; 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..a2f4b27bd --- /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(), + 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..03c92b24f --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx @@ -0,0 +1,74 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } 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 { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshHostGroup", + "deleteSshHostGroup" + ] as const); + + 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 + ) + } + /> +
+ ); +}; 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..cbad2c12b --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -0,0 +1,157 @@ +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-groups/$sshGroupId` as const, + params: { + projectId: currentWorkspace.id, + sshGroupId: group.id + } + }) + } + > + + + + + + ); + })} + +
NameHostsLogin User - Authorized Principals Mapping +
{group.name}- + {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 SSH host group + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteSshHostGroup", { + sshHostGroupId: group.id + }); + }} + disabled={!isAllowed} + icon={} + > + Delete SSH host group + + )} + + +
+
+ {!isPending && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx index 6efe40b36..88faba2ff 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx @@ -63,7 +63,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { return (
- +
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/index.tsx b/frontend/src/pages/ssh/SshHostsPage/components/index.tsx index 5f82c5ad8..96fb67e09 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/index.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/index.tsx @@ -1 +1,2 @@ +export { SshHostGroupsSection } from "./SshHostGroupsSection"; export { SshHostsSection } from "./SshHostsSection"; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 69186b45d..fa2ef2d46 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -107,6 +107,7 @@ import { Route as projectRoleDetailsBySlugPageRouteCertManagerImport } from './p import { Route as certManagerPkiCollectionDetailsByIDPageRoutesImport } from './pages/cert-manager/PkiCollectionDetailsByIDPage/routes' import { Route as projectMemberDetailsByIDPageRouteCertManagerImport } from './pages/project/MemberDetailsByIDPage/route-cert-manager' import { Route as projectIdentityDetailsByIDPageRouteCertManagerImport } from './pages/project/IdentityDetailsByIDPage/route-cert-manager' +import { Route as sshSshGroupDetailsByIDPageRouteImport } from './pages/ssh/SshGroupDetailsByIDPage/route' import { Route as sshSshCaByIDPageRouteImport } from './pages/ssh/SshCaByIDPage/route' import { Route as secretManagerSecretDashboardPageRouteImport } from './pages/secret-manager/SecretDashboardPage/route' import { Route as secretManagerIntegrationsSelectIntegrationAuthPageRouteImport } from './pages/secret-manager/integrations/SelectIntegrationAuthPage/route' @@ -1001,6 +1002,13 @@ const projectIdentityDetailsByIDPageRouteCertManagerRoute = getParentRoute: () => certManagerLayoutRoute, } as any) +const sshSshGroupDetailsByIDPageRouteRoute = + sshSshGroupDetailsByIDPageRouteImport.update({ + id: '/ssh-host-groups/$sshHostGroupId', + path: '/ssh-host-groups/$sshHostGroupId', + getParentRoute: () => sshLayoutRoute, + } as any) + const sshSshCaByIDPageRouteRoute = sshSshCaByIDPageRouteImport.update({ id: '/ca/$caId', path: '/ca/$caId', @@ -2379,6 +2387,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof sshSshCaByIDPageRouteImport parentRoute: typeof sshLayoutImport } + '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId': { + id: '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId' + path: '/ssh-host-groups/$sshHostGroupId' + fullPath: '/ssh/$projectId/ssh-host-groups/$sshHostGroupId' + preLoaderRoute: typeof sshSshGroupDetailsByIDPageRouteImport + parentRoute: typeof sshLayoutImport + } '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': { id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId' path: '/identities/$identityId' @@ -3546,6 +3561,7 @@ interface sshLayoutRouteChildren { sshSettingsPageRouteRoute: typeof sshSettingsPageRouteRoute projectAccessControlPageRouteSshRoute: typeof projectAccessControlPageRouteSshRoute sshSshCaByIDPageRouteRoute: typeof sshSshCaByIDPageRouteRoute + sshSshGroupDetailsByIDPageRouteRoute: typeof sshSshGroupDetailsByIDPageRouteRoute projectIdentityDetailsByIDPageRouteSshRoute: typeof projectIdentityDetailsByIDPageRouteSshRoute projectMemberDetailsByIDPageRouteSshRoute: typeof projectMemberDetailsByIDPageRouteSshRoute projectRoleDetailsBySlugPageRouteSshRoute: typeof projectRoleDetailsBySlugPageRouteSshRoute @@ -3558,6 +3574,7 @@ const sshLayoutRouteChildren: sshLayoutRouteChildren = { sshSettingsPageRouteRoute: sshSettingsPageRouteRoute, projectAccessControlPageRouteSshRoute: projectAccessControlPageRouteSshRoute, sshSshCaByIDPageRouteRoute: sshSshCaByIDPageRouteRoute, + sshSshGroupDetailsByIDPageRouteRoute: sshSshGroupDetailsByIDPageRouteRoute, projectIdentityDetailsByIDPageRouteSshRoute: projectIdentityDetailsByIDPageRouteSshRoute, projectMemberDetailsByIDPageRouteSshRoute: @@ -3866,6 +3883,7 @@ export interface FileRoutesByFullPath { '/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute '/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute '/ssh/$projectId/ca/$caId': typeof sshSshCaByIDPageRouteRoute + '/ssh/$projectId/ssh-host-groups/$sshHostGroupId': typeof sshSshGroupDetailsByIDPageRouteRoute '/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute '/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute '/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute @@ -4042,6 +4060,7 @@ export interface FileRoutesByTo { '/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute '/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute '/ssh/$projectId/ca/$caId': typeof sshSshCaByIDPageRouteRoute + '/ssh/$projectId/ssh-host-groups/$sshHostGroupId': typeof sshSshGroupDetailsByIDPageRouteRoute '/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute '/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute '/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute @@ -4236,6 +4255,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId': typeof sshSshCaByIDPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId': typeof sshSshGroupDetailsByIDPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute @@ -4422,6 +4442,7 @@ export interface FileRouteTypes { | '/secret-manager/$projectId/integrations/select-integration-auth' | '/secret-manager/$projectId/secrets/$envSlug' | '/ssh/$projectId/ca/$caId' + | '/ssh/$projectId/ssh-host-groups/$sshHostGroupId' | '/cert-manager/$projectId/identities/$identityId' | '/cert-manager/$projectId/members/$membershipId' | '/cert-manager/$projectId/pki-collections/$collectionId' @@ -4597,6 +4618,7 @@ export interface FileRouteTypes { | '/secret-manager/$projectId/integrations/select-integration-auth' | '/secret-manager/$projectId/secrets/$envSlug' | '/ssh/$projectId/ca/$caId' + | '/ssh/$projectId/ssh-host-groups/$sshHostGroupId' | '/cert-manager/$projectId/identities/$identityId' | '/cert-manager/$projectId/members/$membershipId' | '/cert-manager/$projectId/pki-collections/$collectionId' @@ -4789,6 +4811,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth' | '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug' | '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId' + | '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId' @@ -5321,6 +5344,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/settings", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId", + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/identities/$identityId", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/members/$membershipId", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/roles/$roleSlug" @@ -5554,6 +5578,10 @@ export const routeTree = rootRoute "filePath": "ssh/SshCaByIDPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout" }, + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId": { + "filePath": "ssh/SshGroupDetailsByIDPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout" + }, "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId": { "filePath": "project/IdentityDetailsByIDPage/route-cert-manager.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 6c5c2832e..302d8bbab 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -312,6 +312,7 @@ const sshRoutes = route("/ssh/$projectId", [ route("/certificates", "ssh/SshCertsPage/route.tsx"), route("/cas", "ssh/SshCasPage/route.tsx"), route("/ca/$caId", "ssh/SshCaByIDPage/route.tsx"), + route("/ssh-host-groups/$sshHostGroupId", "ssh/SshGroupDetailsByIDPage/route.tsx"), route("/settings", "ssh/SettingsPage/route.tsx"), route("/access-management", "project/AccessControlPage/route-ssh.tsx"), route("/roles/$roleSlug", "project/RoleDetailsBySlugPage/route-ssh.tsx"),
Alias