diff --git a/.infisicalignore b/.infisicalignore
index 2c52b2b4f..b00bf0995 100644
--- a/.infisicalignore
+++ b/.infisicalignore
@@ -24,5 +24,7 @@ frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65
frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26
docs/documentation/platform/kms/overview.mdx:generic-api-key:281
docs/documentation/platform/kms/overview.mdx:generic-api-key:344
+frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:85
docs/cli/commands/user.mdx:generic-api-key:51
-frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:76
\ No newline at end of file
+frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:76
+docs/integrations/app-connections/hashicorp-vault.mdx:generic-api-key:188
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index 77db3e59e..6ec542c6b 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -41,6 +41,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/
import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service";
import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service";
+import { TSshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service";
import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
import { TAuthMode } from "@app/server/plugins/auth/inject-identity";
import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service";
@@ -214,6 +215,7 @@ declare module "fastify" {
sshCertificateAuthority: TSshCertificateAuthorityServiceFactory;
sshCertificateTemplate: TSshCertificateTemplateServiceFactory;
sshHost: TSshHostServiceFactory;
+ sshHostGroup: TSshHostGroupServiceFactory;
certificateAuthority: TCertificateAuthorityServiceFactory;
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory;
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index 7ac57afb2..13f3bc306 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -386,6 +386,12 @@ import {
TSshCertificateTemplates,
TSshCertificateTemplatesInsert,
TSshCertificateTemplatesUpdate,
+ TSshHostGroupMemberships,
+ TSshHostGroupMembershipsInsert,
+ TSshHostGroupMembershipsUpdate,
+ TSshHostGroups,
+ TSshHostGroupsInsert,
+ TSshHostGroupsUpdate,
TSshHostLoginUserMappings,
TSshHostLoginUserMappingsInsert,
TSshHostLoginUserMappingsUpdate,
@@ -455,6 +461,16 @@ declare module "knex/types/tables" {
interface Tables {
[TableName.Users]: KnexOriginal.CompositeTableType;
[TableName.Groups]: KnexOriginal.CompositeTableType;
+ [TableName.SshHostGroup]: KnexOriginal.CompositeTableType<
+ TSshHostGroups,
+ TSshHostGroupsInsert,
+ TSshHostGroupsUpdate
+ >;
+ [TableName.SshHostGroupMembership]: KnexOriginal.CompositeTableType<
+ TSshHostGroupMemberships,
+ TSshHostGroupMembershipsInsert,
+ TSshHostGroupMembershipsUpdate
+ >;
[TableName.SshHost]: KnexOriginal.CompositeTableType;
[TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType<
TSshCertificateAuthorities,
diff --git a/backend/src/db/migrations/20250428173025_ssh-host-groups.ts b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts
new file mode 100644
index 000000000..6bac07ae6
--- /dev/null
+++ b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts
@@ -0,0 +1,55 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasTable(TableName.SshHostGroup))) {
+ await knex.schema.createTable(TableName.SshHostGroup, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.timestamps(true, true, true);
+ t.string("projectId").notNullable();
+ t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
+ t.string("name").notNullable();
+ t.unique(["projectId", "name"]);
+ });
+ await createOnUpdateTrigger(knex, TableName.SshHostGroup);
+ }
+
+ if (!(await knex.schema.hasTable(TableName.SshHostGroupMembership))) {
+ await knex.schema.createTable(TableName.SshHostGroupMembership, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.timestamps(true, true, true);
+ t.uuid("sshHostGroupId").notNullable();
+ t.foreign("sshHostGroupId").references("id").inTable(TableName.SshHostGroup).onDelete("CASCADE");
+ t.uuid("sshHostId").notNullable();
+ t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("CASCADE");
+ t.unique(["sshHostGroupId", "sshHostId"]);
+ });
+ await createOnUpdateTrigger(knex, TableName.SshHostGroupMembership);
+ }
+
+ const hasGroupColumn = await knex.schema.hasColumn(TableName.SshHostLoginUser, "sshHostGroupId");
+ if (!hasGroupColumn) {
+ await knex.schema.alterTable(TableName.SshHostLoginUser, (t) => {
+ t.uuid("sshHostGroupId").nullable();
+ t.foreign("sshHostGroupId").references("id").inTable(TableName.SshHostGroup).onDelete("CASCADE");
+ t.uuid("sshHostId").nullable().alter();
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ const hasGroupColumn = await knex.schema.hasColumn(TableName.SshHostLoginUser, "sshHostGroupId");
+ if (hasGroupColumn) {
+ await knex.schema.alterTable(TableName.SshHostLoginUser, (t) => {
+ t.dropColumn("sshHostGroupId");
+ });
+ }
+
+ await knex.schema.dropTableIfExists(TableName.SshHostGroupMembership);
+ await dropOnUpdateTrigger(knex, TableName.SshHostGroupMembership);
+
+ await knex.schema.dropTableIfExists(TableName.SshHostGroup);
+ await dropOnUpdateTrigger(knex, TableName.SshHostGroup);
+}
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index a69a6463c..b71d51908 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -128,6 +128,8 @@ export * from "./ssh-certificate-authority-secrets";
export * from "./ssh-certificate-bodies";
export * from "./ssh-certificate-templates";
export * from "./ssh-certificates";
+export * from "./ssh-host-group-memberships";
+export * from "./ssh-host-groups";
export * from "./ssh-host-login-user-mappings";
export * from "./ssh-host-login-users";
export * from "./ssh-hosts";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index e5af10cd6..7fd77da6c 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -2,6 +2,8 @@ import { z } from "zod";
export enum TableName {
Users = "users",
+ SshHostGroup = "ssh_host_groups",
+ SshHostGroupMembership = "ssh_host_group_memberships",
SshHost = "ssh_hosts",
SshHostLoginUser = "ssh_host_login_users",
SshHostLoginUserMapping = "ssh_host_login_user_mappings",
diff --git a/backend/src/db/schemas/ssh-host-group-memberships.ts b/backend/src/db/schemas/ssh-host-group-memberships.ts
new file mode 100644
index 000000000..80a891e07
--- /dev/null
+++ b/backend/src/db/schemas/ssh-host-group-memberships.ts
@@ -0,0 +1,22 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SshHostGroupMembershipsSchema = z.object({
+ id: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ sshHostGroupId: z.string().uuid(),
+ sshHostId: z.string().uuid()
+});
+
+export type TSshHostGroupMemberships = z.infer;
+export type TSshHostGroupMembershipsInsert = Omit, TImmutableDBKeys>;
+export type TSshHostGroupMembershipsUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/ssh-host-groups.ts b/backend/src/db/schemas/ssh-host-groups.ts
new file mode 100644
index 000000000..5476e7fa1
--- /dev/null
+++ b/backend/src/db/schemas/ssh-host-groups.ts
@@ -0,0 +1,20 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const SshHostGroupsSchema = z.object({
+ id: z.string().uuid(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ projectId: z.string(),
+ name: z.string()
+});
+
+export type TSshHostGroups = z.infer;
+export type TSshHostGroupsInsert = Omit, TImmutableDBKeys>;
+export type TSshHostGroupsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts
index 62454d3c9..6060db903 100644
--- a/backend/src/db/schemas/ssh-host-login-users.ts
+++ b/backend/src/db/schemas/ssh-host-login-users.ts
@@ -11,8 +11,9 @@ export const SshHostLoginUsersSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
- sshHostId: z.string().uuid(),
- loginUser: z.string()
+ sshHostId: z.string().uuid().nullable().optional(),
+ loginUser: z.string(),
+ sshHostGroupId: z.string().uuid().nullable().optional()
});
export type TSshHostLoginUsers = z.infer;
diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts
index a88ebf258..0b8c78586 100644
--- a/backend/src/ee/routes/v1/index.ts
+++ b/backend/src/ee/routes/v1/index.ts
@@ -34,6 +34,7 @@ import { registerSnapshotRouter } from "./snapshot-router";
import { registerSshCaRouter } from "./ssh-certificate-authority-router";
import { registerSshCertRouter } from "./ssh-certificate-router";
import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router";
+import { registerSshHostGroupRouter } from "./ssh-host-group-router";
import { registerSshHostRouter } from "./ssh-host-router";
import { registerTrustedIpRouter } from "./trusted-ip-router";
import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router";
@@ -88,6 +89,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" });
await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" });
await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" });
+ await sshRouter.register(registerSshHostGroupRouter, { prefix: "/host-groups" });
},
{ prefix: "/ssh" }
);
diff --git a/backend/src/ee/routes/v1/ssh-host-group-router.ts b/backend/src/ee/routes/v1/ssh-host-group-router.ts
new file mode 100644
index 000000000..c6f35c7e1
--- /dev/null
+++ b/backend/src/ee/routes/v1/ssh-host-group-router.ts
@@ -0,0 +1,360 @@
+import { z } from "zod";
+
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
+import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema";
+import { EHostGroupMembershipFilter } from "@app/ee/services/ssh-host-group/ssh-host-group-types";
+import { ApiDocsTags, SSH_HOST_GROUPS } from "@app/lib/api-docs";
+import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { slugSchema } from "@app/server/lib/schemas";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+
+export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ method: "GET",
+ url: "/:sshHostGroupId",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Get SSH Host Group",
+ params: z.object({
+ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId)
+ }),
+ response: {
+ 200: sanitizedSshHostGroup.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const sshHostGroup = await server.services.sshHostGroup.getSshHostGroup({
+ sshHostGroupId: req.params.sshHostGroupId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHostGroup.projectId,
+ event: {
+ type: EventType.GET_SSH_HOST_GROUP,
+ metadata: {
+ sshHostGroupId: sshHostGroup.id,
+ name: sshHostGroup.name
+ }
+ }
+ });
+
+ return sshHostGroup;
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Create SSH Host Group",
+ body: z.object({
+ projectId: z.string().describe(SSH_HOST_GROUPS.CREATE.projectId),
+ name: slugSchema({ min: 1, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.CREATE.name),
+ loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOST_GROUPS.CREATE.loginMappings)
+ }),
+ response: {
+ 200: sanitizedSshHostGroup.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const sshHostGroup = await server.services.sshHostGroup.createSshHostGroup({
+ ...req.body,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHostGroup.projectId,
+ event: {
+ type: EventType.CREATE_SSH_HOST_GROUP,
+ metadata: {
+ sshHostGroupId: sshHostGroup.id,
+ name: sshHostGroup.name,
+ loginMappings: sshHostGroup.loginMappings
+ }
+ }
+ });
+
+ return sshHostGroup;
+ }
+ });
+
+ server.route({
+ method: "PATCH",
+ url: "/:sshHostGroupId",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Update SSH Host Group",
+ params: z.object({
+ sshHostGroupId: z.string().trim().describe(SSH_HOST_GROUPS.UPDATE.sshHostGroupId)
+ }),
+ body: z.object({
+ name: slugSchema({ min: 1, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.UPDATE.name).optional(),
+ loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOST_GROUPS.UPDATE.loginMappings)
+ }),
+ response: {
+ 200: sanitizedSshHostGroup.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ handler: async (req) => {
+ const sshHostGroup = await server.services.sshHostGroup.updateSshHostGroup({
+ sshHostGroupId: req.params.sshHostGroupId,
+ ...req.body,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHostGroup.projectId,
+ event: {
+ type: EventType.UPDATE_SSH_HOST_GROUP,
+ metadata: {
+ sshHostGroupId: sshHostGroup.id,
+ name: sshHostGroup.name,
+ loginMappings: sshHostGroup.loginMappings
+ }
+ }
+ });
+
+ return sshHostGroup;
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: "/:sshHostGroupId",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Delete SSH Host Group",
+ params: z.object({
+ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE.sshHostGroupId)
+ }),
+ response: {
+ 200: sanitizedSshHostGroup.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const sshHostGroup = await server.services.sshHostGroup.deleteSshHostGroup({
+ sshHostGroupId: req.params.sshHostGroupId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHostGroup.projectId,
+ event: {
+ type: EventType.DELETE_SSH_HOST_GROUP,
+ metadata: {
+ sshHostGroupId: sshHostGroup.id,
+ name: sshHostGroup.name
+ }
+ }
+ });
+
+ return sshHostGroup;
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:sshHostGroupId/hosts",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Get SSH Hosts in a Host Group",
+ params: z.object({
+ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId)
+ }),
+ querystring: z.object({
+ filter: z.nativeEnum(EHostGroupMembershipFilter).optional().describe(SSH_HOST_GROUPS.GET.filter)
+ }),
+ response: {
+ 200: z.object({
+ hosts: sanitizedSshHost
+ .pick({
+ id: true,
+ hostname: true,
+ alias: true
+ })
+ .merge(
+ z.object({
+ isPartOfGroup: z.boolean(),
+ joinedGroupAt: z.date().nullable()
+ })
+ )
+ .array(),
+ totalCount: z.number()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { sshHostGroup, hosts, totalCount } = await server.services.sshHostGroup.listSshHostGroupHosts({
+ sshHostGroupId: req.params.sshHostGroupId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ ...req.query
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHostGroup.projectId,
+ event: {
+ type: EventType.GET_SSH_HOST_GROUP_HOSTS,
+ metadata: {
+ sshHostGroupId: req.params.sshHostGroupId,
+ name: sshHostGroup.name
+ }
+ }
+ });
+
+ return { hosts, totalCount };
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/:sshHostGroupId/hosts/:hostId",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Add an SSH Host to a Host Group",
+ params: z.object({
+ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.sshHostGroupId),
+ hostId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.hostId)
+ }),
+ response: {
+ 200: sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { sshHostGroup, sshHost } = await server.services.sshHostGroup.addHostToSshHostGroup({
+ sshHostGroupId: req.params.sshHostGroupId,
+ hostId: req.params.hostId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHost.projectId,
+ event: {
+ type: EventType.ADD_HOST_TO_SSH_HOST_GROUP,
+ metadata: {
+ sshHostGroupId: sshHostGroup.id,
+ sshHostId: sshHost.id,
+ hostname: sshHost.hostname
+ }
+ }
+ });
+
+ return sshHost;
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: "/:sshHostGroupId/hosts/:hostId",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHostGroups],
+ description: "Remove an SSH Host from a Host Group",
+ params: z.object({
+ sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.sshHostGroupId),
+ hostId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.hostId)
+ }),
+ response: {
+ 200: sanitizedSshHost.extend({
+ loginMappings: z.array(loginMappingSchema)
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { sshHostGroup, sshHost } = await server.services.sshHostGroup.removeHostFromSshHostGroup({
+ sshHostGroupId: req.params.sshHostGroupId,
+ hostId: req.params.hostId,
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: sshHost.projectId,
+ event: {
+ type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP,
+ metadata: {
+ sshHostGroupId: sshHostGroup.id,
+ sshHostId: sshHost.id,
+ hostname: sshHost.hostname
+ }
+ }
+ });
+
+ return sshHost;
+ }
+ });
+};
diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts
index 9db642d4d..93748c27f 100644
--- a/backend/src/ee/routes/v1/ssh-host-router.ts
+++ b/backend/src/ee/routes/v1/ssh-host-router.ts
@@ -3,8 +3,9 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
+import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types";
import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators";
-import { SSH_HOSTS } from "@app/lib/api-docs";
+import { ApiDocsTags, SSH_HOSTS } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { publicSshCaLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
@@ -21,10 +22,16 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
response: {
200: z.array(
sanitizedSshHost.extend({
- loginMappings: z.array(loginMappingSchema)
+ loginMappings: loginMappingSchema
+ .extend({
+ source: z.nativeEnum(LoginMappingSource)
+ })
+ .array()
})
)
}
@@ -49,12 +56,18 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
params: z.object({
sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId)
}),
response: {
200: sanitizedSshHost.extend({
- loginMappings: z.array(loginMappingSchema)
+ loginMappings: loginMappingSchema
+ .extend({
+ source: z.nativeEnum(LoginMappingSource)
+ })
+ .array()
})
}
},
@@ -91,7 +104,9 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit
},
schema: {
- description: "Add an SSH Host",
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
+ description: "Register SSH Host",
body: z.object({
projectId: z.string().describe(SSH_HOSTS.CREATE.projectId),
hostname: z
@@ -119,7 +134,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: sanitizedSshHost.extend({
- loginMappings: z.array(loginMappingSchema)
+ loginMappings: loginMappingSchema
+ .extend({
+ source: z.nativeEnum(LoginMappingSource)
+ })
+ .array()
})
}
},
@@ -163,6 +182,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
description: "Update SSH Host",
params: z.object({
sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId)
@@ -192,7 +213,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: sanitizedSshHost.extend({
- loginMappings: z.array(loginMappingSchema)
+ loginMappings: loginMappingSchema
+ .extend({
+ source: z.nativeEnum(LoginMappingSource)
+ })
+ .array()
})
}
},
@@ -235,12 +260,19 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit
},
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
+ description: "Delete SSH Host",
params: z.object({
sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId)
}),
response: {
200: sanitizedSshHost.extend({
- loginMappings: z.array(loginMappingSchema)
+ loginMappings: loginMappingSchema
+ .extend({
+ source: z.nativeEnum(LoginMappingSource)
+ })
+ .array()
})
}
},
@@ -278,6 +310,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
description: "Issue SSH certificate for user",
params: z.object({
sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId)
@@ -350,6 +384,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
description: "Issue SSH certificate for host",
params: z.object({
sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId)
@@ -414,6 +450,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
rateLimit: publicSshCaLimit
},
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
description: "Get public key of the user SSH CA linked to the host",
params: z.object({
sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId)
@@ -435,6 +473,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
rateLimit: publicSshCaLimit
},
schema: {
+ hide: false,
+ tags: [ApiDocsTags.SshHosts],
description: "Get public key of the host SSH CA linked to the host",
params: z.object({
sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId)
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index 9142212a4..1f4badfb5 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -12,6 +12,7 @@ import {
import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types";
+import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types";
import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher";
import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types";
import { TProjectPermission } from "@app/lib/types";
@@ -192,12 +193,19 @@ export enum EventType {
UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template",
DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template",
GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template",
+ GET_SSH_HOST = "get-ssh-host",
CREATE_SSH_HOST = "create-ssh-host",
UPDATE_SSH_HOST = "update-ssh-host",
DELETE_SSH_HOST = "delete-ssh-host",
- GET_SSH_HOST = "get-ssh-host",
ISSUE_SSH_HOST_USER_CERT = "issue-ssh-host-user-cert",
ISSUE_SSH_HOST_HOST_CERT = "issue-ssh-host-host-cert",
+ GET_SSH_HOST_GROUP = "get-ssh-host-group",
+ CREATE_SSH_HOST_GROUP = "create-ssh-host-group",
+ UPDATE_SSH_HOST_GROUP = "update-ssh-host-group",
+ DELETE_SSH_HOST_GROUP = "delete-ssh-host-group",
+ GET_SSH_HOST_GROUP_HOSTS = "get-ssh-host-group-hosts",
+ ADD_HOST_TO_SSH_HOST_GROUP = "add-host-to-ssh-host-group",
+ REMOVE_HOST_FROM_SSH_HOST_GROUP = "remove-host-from-ssh-host-group",
CREATE_CA = "create-certificate-authority",
GET_CA = "get-certificate-authority",
UPDATE_CA = "update-certificate-authority",
@@ -1512,12 +1520,7 @@ interface CreateSshHost {
alias: string | null;
userCertTtl: string;
hostCertTtl: string;
- loginMappings: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings: TLoginMapping[];
userSshCaId: string;
hostSshCaId: string;
};
@@ -1531,12 +1534,7 @@ interface UpdateSshHost {
alias?: string | null;
userCertTtl?: string;
hostCertTtl?: string;
- loginMappings?: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings?: TLoginMapping[];
userSshCaId?: string;
hostSshCaId?: string;
};
@@ -1580,6 +1578,66 @@ interface IssueSshHostHostCert {
};
}
+interface GetSshHostGroupEvent {
+ type: EventType.GET_SSH_HOST_GROUP;
+ metadata: {
+ sshHostGroupId: string;
+ name: string;
+ };
+}
+
+interface CreateSshHostGroupEvent {
+ type: EventType.CREATE_SSH_HOST_GROUP;
+ metadata: {
+ sshHostGroupId: string;
+ name: string;
+ loginMappings: TLoginMapping[];
+ };
+}
+
+interface UpdateSshHostGroupEvent {
+ type: EventType.UPDATE_SSH_HOST_GROUP;
+ metadata: {
+ sshHostGroupId: string;
+ name?: string;
+ loginMappings?: TLoginMapping[];
+ };
+}
+
+interface DeleteSshHostGroupEvent {
+ type: EventType.DELETE_SSH_HOST_GROUP;
+ metadata: {
+ sshHostGroupId: string;
+ name: string;
+ };
+}
+
+interface GetSshHostGroupHostsEvent {
+ type: EventType.GET_SSH_HOST_GROUP_HOSTS;
+ metadata: {
+ sshHostGroupId: string;
+ name: string;
+ };
+}
+
+interface AddHostToSshHostGroupEvent {
+ type: EventType.ADD_HOST_TO_SSH_HOST_GROUP;
+ metadata: {
+ sshHostGroupId: string;
+ sshHostId: string;
+ hostname: string;
+ };
+}
+
+interface RemoveHostFromSshHostGroupEvent {
+ type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP;
+ metadata: {
+ sshHostGroupId: string;
+ sshHostId: string;
+ hostname: string;
+ };
+}
+
interface CreateCa {
type: EventType.CREATE_CA;
metadata: {
@@ -2828,6 +2886,13 @@ export type Event =
| CreateAppConnectionEvent
| UpdateAppConnectionEvent
| DeleteAppConnectionEvent
+ | GetSshHostGroupEvent
+ | CreateSshHostGroupEvent
+ | UpdateSshHostGroupEvent
+ | DeleteSshHostGroupEvent
+ | GetSshHostGroupHostsEvent
+ | AddHostToSshHostGroupEvent
+ | RemoveHostFromSshHostGroupEvent
| CreateSharedSecretEvent
| DeleteSharedSecretEvent
| ReadSharedSecretEvent
diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts
index 59f82c05d..2458454da 100644
--- a/backend/src/ee/services/group/group-dal.ts
+++ b/backend/src/ee/services/group/group-dal.ts
@@ -153,7 +153,7 @@ export const groupDALFactory = (db: TDbClient) => {
totalCount: Number(members?.[0]?.total_count ?? 0)
};
} catch (error) {
- throw new DatabaseError({ error, name: "Find all org members" });
+ throw new DatabaseError({ error, name: "Find all user group members" });
}
};
diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts
index 360b39f28..6a8f807ad 100644
--- a/backend/src/ee/services/license/__mocks__/license-fns.ts
+++ b/backend/src/ee/services/license/__mocks__/license-fns.ts
@@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => {
has_used_trial: true,
secretApproval: true,
secretRotation: true,
- caCrl: false
+ caCrl: false,
+ sshHostGroups: false
};
};
diff --git a/backend/src/ee/services/license/licence-enums.ts b/backend/src/ee/services/license/licence-enums.ts
index 047eb0a38..8812621f2 100644
--- a/backend/src/ee/services/license/licence-enums.ts
+++ b/backend/src/ee/services/license/licence-enums.ts
@@ -10,6 +10,7 @@ export const BillingPlanRows = {
CustomAlerts: { name: "Custom alerts", field: "customAlerts" },
AuditLogs: { name: "Audit logs", field: "auditLogs" },
SamlSSO: { name: "SAML SSO", field: "samlSSO" },
+ SshHostGroups: { name: "SSH Host Groups", field: "sshHostGroups" },
Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" },
OidcSSO: { name: "OIDC SSO", field: "oidcSSO" },
SecretApproval: { name: "Secret approvals", field: "secretApproval" },
diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts
index 548f6e82b..b7ae6f7ee 100644
--- a/backend/src/ee/services/license/license-fns.ts
+++ b/backend/src/ee/services/license/license-fns.ts
@@ -53,7 +53,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enforceMfa: false,
projectTemplates: false,
kmip: false,
- gateway: false
+ gateway: false,
+ sshHostGroups: false
});
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts
index 6f0d82344..358849fb2 100644
--- a/backend/src/ee/services/license/license-types.ts
+++ b/backend/src/ee/services/license/license-types.ts
@@ -71,6 +71,7 @@ export type TFeatureSet = {
projectTemplates: false;
kmip: false;
gateway: false;
+ sshHostGroups: false;
};
export type TOrgPlansTableDTO = {
diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts
index 8e6645073..319a0259a 100644
--- a/backend/src/ee/services/permission/project-permission.ts
+++ b/backend/src/ee/services/permission/project-permission.ts
@@ -134,6 +134,7 @@ export enum ProjectPermissionSub {
SshCertificates = "ssh-certificates",
SshCertificateTemplates = "ssh-certificate-templates",
SshHosts = "ssh-hosts",
+ SshHostGroups = "ssh-host-groups",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
@@ -240,6 +241,7 @@ export type ProjectPermissionSet =
ProjectPermissionSshHostActions,
ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields)
]
+ | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs]
@@ -508,6 +510,12 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
+ z.object({
+ subject: z.literal(ProjectPermissionSub.SshHostGroups).describe("The entity this permission pertains to."),
+ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
+ "Describe what action an entity can take."
+ )
+ }),
z.object({
subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
@@ -686,7 +694,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
- ProjectPermissionSub.SshCertificateTemplates
+ ProjectPermissionSub.SshCertificateTemplates,
+ ProjectPermissionSub.SshHostGroups
].forEach((el) => {
can(
[
diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts
new file mode 100644
index 000000000..08242d4cb
--- /dev/null
+++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts
@@ -0,0 +1,225 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { BadRequestError, DatabaseError } from "@app/lib/errors";
+import { groupBy, unique } from "@app/lib/fn";
+import { ormify } from "@app/lib/knex";
+
+import { EHostGroupMembershipFilter } from "./ssh-host-group-types";
+
+export type TSshHostGroupDALFactory = ReturnType;
+
+export const sshHostGroupDALFactory = (db: TDbClient) => {
+ const sshHostGroupOrm = ormify(db, TableName.SshHostGroup);
+
+ const findSshHostGroupsWithLoginMappings = async (projectId: string, tx?: Knex) => {
+ try {
+ // First, get all the SSH host groups with their login mappings
+ const rows = await (tx || db.replicaNode())(TableName.SshHostGroup)
+ .leftJoin(
+ TableName.SshHostLoginUser,
+ `${TableName.SshHostGroup}.id`,
+ `${TableName.SshHostLoginUser}.sshHostGroupId`
+ )
+ .leftJoin(
+ TableName.SshHostLoginUserMapping,
+ `${TableName.SshHostLoginUser}.id`,
+ `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
+ )
+ .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
+ .where(`${TableName.SshHostGroup}.projectId`, projectId)
+ .select(
+ db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"),
+ db.ref("projectId").withSchema(TableName.SshHostGroup),
+ db.ref("name").withSchema(TableName.SshHostGroup),
+ db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
+ db.ref("username").withSchema(TableName.Users),
+ db.ref("userId").withSchema(TableName.SshHostLoginUserMapping)
+ )
+ .orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc");
+
+ const hostsGrouped = groupBy(rows, (r) => r.sshHostGroupId);
+
+ const hostGroupIds = Object.keys(hostsGrouped);
+
+ type HostCountRow = {
+ sshHostGroupId: string;
+ host_count: string;
+ };
+
+ const hostCountsQuery = (await (tx ||
+ db
+ .replicaNode()(TableName.SshHostGroupMembership)
+ .select(`${TableName.SshHostGroupMembership}.sshHostGroupId`, db.raw(`count(*) as host_count`))
+ .whereIn(`${TableName.SshHostGroupMembership}.sshHostGroupId`, hostGroupIds)
+ .groupBy(`${TableName.SshHostGroupMembership}.sshHostGroupId`))) as HostCountRow[];
+
+ const hostCountsMap = hostCountsQuery.reduce>((acc, { sshHostGroupId, host_count }) => {
+ acc[sshHostGroupId] = Number(host_count);
+ return acc;
+ }, {});
+
+ return Object.values(hostsGrouped).map((hostRows) => {
+ const { sshHostGroupId, name } = hostRows[0];
+ const loginMappingGrouped = groupBy(
+ hostRows.filter((r) => r.loginUser),
+ (r) => r.loginUser
+ );
+ const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
+ loginUser,
+ allowedPrincipals: {
+ usernames: unique(entries.map((e) => e.username)).filter(Boolean)
+ }
+ }));
+ return {
+ id: sshHostGroupId,
+ projectId,
+ name,
+ loginMappings,
+ hostCount: hostCountsMap[sshHostGroupId] ?? 0
+ };
+ });
+ } catch (error) {
+ throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupsWithLoginMappings` });
+ }
+ };
+
+ const findSshHostGroupByIdWithLoginMappings = async (sshHostGroupId: string, tx?: Knex) => {
+ try {
+ const rows = await (tx || db.replicaNode())(TableName.SshHostGroup)
+ .leftJoin(
+ TableName.SshHostLoginUser,
+ `${TableName.SshHostGroup}.id`,
+ `${TableName.SshHostLoginUser}.sshHostGroupId`
+ )
+ .leftJoin(
+ TableName.SshHostLoginUserMapping,
+ `${TableName.SshHostLoginUser}.id`,
+ `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
+ )
+ .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
+ .where(`${TableName.SshHostGroup}.id`, sshHostGroupId)
+ .select(
+ db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"),
+ db.ref("projectId").withSchema(TableName.SshHostGroup),
+ db.ref("name").withSchema(TableName.SshHostGroup),
+ db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
+ db.ref("username").withSchema(TableName.Users),
+ db.ref("userId").withSchema(TableName.SshHostLoginUserMapping)
+ );
+
+ if (rows.length === 0) return null;
+
+ const { sshHostGroupId: id, projectId, name } = rows[0];
+
+ const loginMappingGrouped = groupBy(
+ rows.filter((r) => r.loginUser),
+ (r) => r.loginUser
+ );
+
+ const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
+ loginUser,
+ allowedPrincipals: {
+ usernames: unique(entries.map((e) => e.username)).filter(Boolean)
+ }
+ }));
+
+ return {
+ id,
+ projectId,
+ name,
+ loginMappings
+ };
+ } catch (error) {
+ throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupByIdWithLoginMappings` });
+ }
+ };
+
+ const findAllSshHostsInGroup = async ({
+ sshHostGroupId,
+ offset = 0,
+ limit,
+ filter
+ }: {
+ sshHostGroupId: string;
+ offset?: number;
+ limit?: number;
+ filter?: EHostGroupMembershipFilter;
+ }) => {
+ try {
+ const sshHostGroup = await db
+ .replicaNode()(TableName.SshHostGroup)
+ .where(`${TableName.SshHostGroup}.id`, sshHostGroupId)
+ .select("projectId")
+ .first();
+
+ if (!sshHostGroup) {
+ throw new BadRequestError({
+ message: `SSH host group with ID ${sshHostGroupId} not found`
+ });
+ }
+
+ const query = db
+ .replicaNode()(TableName.SshHost)
+ .where(`${TableName.SshHost}.projectId`, sshHostGroup.projectId)
+ .leftJoin(TableName.SshHostGroupMembership, (bd) => {
+ bd.on(`${TableName.SshHostGroupMembership}.sshHostId`, "=", `${TableName.SshHost}.id`).andOn(
+ `${TableName.SshHostGroupMembership}.sshHostGroupId`,
+ "=",
+ db.raw("?", [sshHostGroupId])
+ );
+ })
+ .select(
+ db.ref("id").withSchema(TableName.SshHost),
+ db.ref("hostname").withSchema(TableName.SshHost),
+ db.ref("alias").withSchema(TableName.SshHost),
+ db.ref("sshHostGroupId").withSchema(TableName.SshHostGroupMembership),
+ db.ref("createdAt").withSchema(TableName.SshHostGroupMembership).as("joinedGroupAt"),
+ db.raw(`count(*) OVER() as total_count`)
+ )
+ .offset(offset)
+ .orderBy(`${TableName.SshHost}.hostname`, "asc");
+
+ if (limit) {
+ void query.limit(limit);
+ }
+
+ if (filter) {
+ switch (filter) {
+ case EHostGroupMembershipFilter.GROUP_MEMBERS:
+ void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is not", null);
+ break;
+ case EHostGroupMembershipFilter.NON_GROUP_MEMBERS:
+ void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is", null);
+ break;
+ default:
+ break;
+ }
+ }
+
+ const hosts = await query;
+
+ return {
+ hosts: hosts.map(({ id, hostname, alias, sshHostGroupId: memberGroupId, joinedGroupAt }) => ({
+ id,
+ hostname,
+ alias,
+ isPartOfGroup: !!memberGroupId,
+ joinedGroupAt
+ })),
+ // @ts-expect-error col select is raw and not strongly typed
+ totalCount: Number(hosts?.[0]?.total_count ?? 0)
+ };
+ } catch (error) {
+ throw new DatabaseError({ error, name: `${TableName.SshHostGroupMembership}: FindAllSshHostsInGroup` });
+ }
+ };
+
+ return {
+ findSshHostGroupsWithLoginMappings,
+ findSshHostGroupByIdWithLoginMappings,
+ findAllSshHostsInGroup,
+ ...sshHostGroupOrm
+ };
+};
diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts
new file mode 100644
index 000000000..54179c2d9
--- /dev/null
+++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts
@@ -0,0 +1,13 @@
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { ormify } from "@app/lib/knex";
+
+export type TSshHostGroupMembershipDALFactory = ReturnType;
+
+export const sshHostGroupMembershipDALFactory = (db: TDbClient) => {
+ const sshHostGroupMembershipOrm = ormify(db, TableName.SshHostGroupMembership);
+
+ return {
+ ...sshHostGroupMembershipOrm
+ };
+};
diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts
new file mode 100644
index 000000000..4ebf3000d
--- /dev/null
+++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts
@@ -0,0 +1,7 @@
+import { SshHostGroupsSchema } from "@app/db/schemas";
+
+export const sanitizedSshHostGroup = SshHostGroupsSchema.pick({
+ id: true,
+ projectId: true,
+ name: true
+});
diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts
new file mode 100644
index 000000000..751116895
--- /dev/null
+++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts
@@ -0,0 +1,397 @@
+import { ForbiddenError } from "@casl/ability";
+
+import { ActionProjectType } from "@app/db/schemas";
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
+import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
+import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal";
+import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal";
+import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
+import { TSshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal";
+import { BadRequestError, NotFoundError } from "@app/lib/errors";
+import { TProjectDALFactory } from "@app/services/project/project-dal";
+import { TUserDALFactory } from "@app/services/user/user-dal";
+
+import { TLicenseServiceFactory } from "../license/license-service";
+import { createSshLoginMappings } from "../ssh-host/ssh-host-fns";
+import {
+ TAddHostToSshHostGroupDTO,
+ TCreateSshHostGroupDTO,
+ TDeleteSshHostGroupDTO,
+ TGetSshHostGroupDTO,
+ TListSshHostGroupHostsDTO,
+ TRemoveHostFromSshHostGroupDTO,
+ TUpdateSshHostGroupDTO
+} from "./ssh-host-group-types";
+
+type TSshHostGroupServiceFactoryDep = {
+ projectDAL: Pick;
+ sshHostDAL: Pick;
+ sshHostGroupDAL: Pick<
+ TSshHostGroupDALFactory,
+ | "create"
+ | "updateById"
+ | "findById"
+ | "deleteById"
+ | "transaction"
+ | "findSshHostGroupByIdWithLoginMappings"
+ | "findAllSshHostsInGroup"
+ | "findOne"
+ | "find"
+ >;
+ sshHostGroupMembershipDAL: Pick;
+ sshHostLoginUserDAL: Pick;
+ sshHostLoginUserMappingDAL: Pick;
+ userDAL: Pick;
+ permissionService: Pick;
+ licenseService: Pick;
+};
+
+export type TSshHostGroupServiceFactory = ReturnType;
+
+export const sshHostGroupServiceFactory = ({
+ projectDAL,
+ sshHostDAL,
+ sshHostGroupDAL,
+ sshHostGroupMembershipDAL,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ licenseService
+}: TSshHostGroupServiceFactoryDep) => {
+ const createSshHostGroup = async ({
+ projectId,
+ name,
+ loginMappings,
+ actorId,
+ actorAuthMethod,
+ actor,
+ actorOrgId
+ }: TCreateSshHostGroupDTO) => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHostGroups);
+
+ const plan = await licenseService.getPlan(actorOrgId);
+ if (!plan.sshHostGroups)
+ throw new BadRequestError({
+ message: "Failed to create SSH host group due to plan restriction. Upgrade plan to create group."
+ });
+
+ const newSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => {
+ // (dangtony98): room to optimize check to ensure that
+ // the SSH host group name is unique across the whole org
+ const project = await projectDAL.findById(projectId, tx);
+ if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` });
+ const projects = await projectDAL.find(
+ {
+ orgId: project.orgId
+ },
+ { tx }
+ );
+
+ const existingSshHostGroup = await sshHostGroupDAL.find(
+ {
+ name,
+ $in: {
+ projectId: projects.map((p) => p.id)
+ }
+ },
+ { tx }
+ );
+
+ if (existingSshHostGroup.length) {
+ throw new BadRequestError({
+ message: `SSH host group with name '${name}' already exists in the organization`
+ });
+ }
+
+ const sshHostGroup = await sshHostGroupDAL.create(
+ {
+ projectId,
+ name
+ },
+ tx
+ );
+
+ await createSshLoginMappings({
+ sshHostGroupId: sshHostGroup.id,
+ loginMappings,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ tx
+ });
+
+ const newSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(
+ sshHostGroup.id,
+ tx
+ );
+ if (!newSshHostGroupWithLoginMappings) {
+ throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` });
+ }
+
+ return newSshHostGroupWithLoginMappings;
+ });
+
+ return newSshHostGroup;
+ };
+
+ const updateSshHostGroup = async ({
+ sshHostGroupId,
+ name,
+ loginMappings,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TUpdateSshHostGroupDTO) => {
+ const sshHostGroup = await sshHostGroupDAL.findById(sshHostGroupId);
+ if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
+
+ const plan = await licenseService.getPlan(actorOrgId);
+ if (!plan.sshHostGroups)
+ throw new BadRequestError({
+ message: "Failed to update SSH host group due to plan restriction. Upgrade plan to update group."
+ });
+
+ const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => {
+ await sshHostGroupDAL.updateById(
+ sshHostGroupId,
+ {
+ name
+ },
+ tx
+ );
+ if (loginMappings) {
+ await sshHostLoginUserDAL.delete({ sshHostGroupId: sshHostGroup.id }, tx);
+ if (loginMappings.length) {
+ await createSshLoginMappings({
+ sshHostGroupId: sshHostGroup.id,
+ loginMappings,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ tx
+ });
+ }
+ }
+
+ const updatedSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(
+ sshHostGroup.id,
+ tx
+ );
+ if (!updatedSshHostGroupWithLoginMappings) {
+ throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` });
+ }
+
+ return updatedSshHostGroupWithLoginMappings;
+ });
+
+ return updatedSshHostGroup;
+ };
+
+ const getSshHostGroup = async ({
+ sshHostGroupId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TGetSshHostGroupDTO) => {
+ const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
+ if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
+
+ return sshHostGroup;
+ };
+
+ const deleteSshHostGroup = async ({
+ sshHostGroupId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TDeleteSshHostGroupDTO) => {
+ const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
+ if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SshHostGroups);
+
+ await sshHostGroupDAL.deleteById(sshHostGroupId);
+
+ return sshHostGroup;
+ };
+
+ const listSshHostGroupHosts = async ({
+ sshHostGroupId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ filter
+ }: TListSshHostGroupHostsDTO) => {
+ const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
+ if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
+
+ const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId, filter });
+ return { sshHostGroup, hosts, totalCount };
+ };
+
+ const addHostToSshHostGroup = async ({
+ sshHostGroupId,
+ hostId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TAddHostToSshHostGroupDTO) => {
+ const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
+ if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
+
+ const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId);
+ if (!sshHost) {
+ throw new NotFoundError({
+ message: `SSH host with ID ${hostId} not found`
+ });
+ }
+
+ if (sshHostGroup.projectId !== sshHost.projectId) {
+ throw new BadRequestError({
+ message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}`
+ });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
+
+ await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId });
+
+ return { sshHostGroup, sshHost };
+ };
+
+ const removeHostFromSshHostGroup = async ({
+ sshHostGroupId,
+ hostId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TRemoveHostFromSshHostGroupDTO) => {
+ const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
+ if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
+
+ const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId);
+ if (!sshHost) {
+ throw new NotFoundError({
+ message: `SSH host with ID ${hostId} not found`
+ });
+ }
+
+ if (sshHostGroup.projectId !== sshHost.projectId) {
+ throw new BadRequestError({
+ message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}`
+ });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: sshHostGroup.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
+
+ const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({
+ sshHostGroupId,
+ sshHostId: hostId
+ });
+
+ if (!sshHostGroupMembership) {
+ throw new NotFoundError({
+ message: `SSH host with ID ${hostId} not found in SSH host group with ID ${sshHostGroupId}`
+ });
+ }
+
+ await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id);
+
+ return { sshHostGroup, sshHost };
+ };
+
+ return {
+ createSshHostGroup,
+ getSshHostGroup,
+ deleteSshHostGroup,
+ updateSshHostGroup,
+ listSshHostGroupHosts,
+ addHostToSshHostGroup,
+ removeHostFromSshHostGroup
+ };
+};
diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts
new file mode 100644
index 000000000..3485b5d26
--- /dev/null
+++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts
@@ -0,0 +1,46 @@
+import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types";
+import { TProjectPermission } from "@app/lib/types";
+
+export type TCreateSshHostGroupDTO = {
+ name: string;
+ loginMappings: TLoginMapping[];
+} & TProjectPermission;
+
+export type TUpdateSshHostGroupDTO = {
+ sshHostGroupId: string;
+ name?: string;
+ loginMappings?: {
+ loginUser: string;
+ allowedPrincipals: {
+ usernames: string[];
+ };
+ }[];
+} & Omit;
+
+export type TGetSshHostGroupDTO = {
+ sshHostGroupId: string;
+} & Omit;
+
+export type TDeleteSshHostGroupDTO = {
+ sshHostGroupId: string;
+} & Omit;
+
+export type TListSshHostGroupHostsDTO = {
+ sshHostGroupId: string;
+ filter?: EHostGroupMembershipFilter;
+} & Omit;
+
+export type TAddHostToSshHostGroupDTO = {
+ sshHostGroupId: string;
+ hostId: string;
+} & Omit;
+
+export type TRemoveHostFromSshHostGroupDTO = {
+ sshHostGroupId: string;
+ hostId: string;
+} & Omit;
+
+export enum EHostGroupMembershipFilter {
+ GROUP_MEMBERS = "group-members",
+ NON_GROUP_MEMBERS = "non-group-members"
+}
diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts
index 3c9755e65..e66f7da7a 100644
--- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts
+++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts
@@ -6,6 +6,8 @@ import { DatabaseError } from "@app/lib/errors";
import { groupBy, unique } from "@app/lib/fn";
import { ormify } from "@app/lib/knex";
+import { LoginMappingSource } from "./ssh-host-types";
+
export type TSshHostDALFactory = ReturnType;
export const sshHostDALFactory = (db: TDbClient) => {
@@ -13,20 +15,22 @@ export const sshHostDALFactory = (db: TDbClient) => {
const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => {
try {
- const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first();
+ const knex = tx || db.replicaNode();
+
+ const user = await knex(TableName.Users).where({ id: userId }).select("username").first();
if (!user) {
throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") });
}
- const rows = await (tx || db.replicaNode())(TableName.SshHost)
+ // get hosts where user has direct login mappings
+ const directHostRows = await knex(TableName.SshHost)
.leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`)
.leftJoin(
TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
)
- .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`)
.whereIn(`${TableName.SshHost}.projectId`, projectIds)
.andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId)
.select(
@@ -37,26 +41,70 @@ export const sshHostDALFactory = (db: TDbClient) => {
db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
- db.ref("username").withSchema(TableName.Users),
- db.ref("userId").withSchema(TableName.SshHostLoginUserMapping),
db.ref("userSshCaId").withSchema(TableName.SshHost),
db.ref("hostSshCaId").withSchema(TableName.SshHost)
- )
- .orderBy(`${TableName.SshHost}.updatedAt`, "desc");
+ );
- const grouped = groupBy(rows, (r) => r.sshHostId);
- return Object.values(grouped).map((hostRows) => {
+ // get hosts where user has login mappings via host groups
+ const groupHostRows = await knex(TableName.SshHostGroupMembership)
+ .join(
+ TableName.SshHostLoginUser,
+ `${TableName.SshHostGroupMembership}.sshHostGroupId`,
+ `${TableName.SshHostLoginUser}.sshHostGroupId`
+ )
+ .leftJoin(
+ TableName.SshHostLoginUserMapping,
+ `${TableName.SshHostLoginUser}.id`,
+ `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
+ )
+ .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`)
+ .whereIn(`${TableName.SshHost}.projectId`, projectIds)
+ .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId)
+ .select(
+ db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
+ db.ref("projectId").withSchema(TableName.SshHost),
+ db.ref("hostname").withSchema(TableName.SshHost),
+ db.ref("alias").withSchema(TableName.SshHost),
+ db.ref("userCertTtl").withSchema(TableName.SshHost),
+ db.ref("hostCertTtl").withSchema(TableName.SshHost),
+ db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
+ db.ref("userSshCaId").withSchema(TableName.SshHost),
+ db.ref("hostSshCaId").withSchema(TableName.SshHost)
+ );
+
+ const directHostRowsWithSource = directHostRows.map((row) => ({
+ ...row,
+ source: LoginMappingSource.HOST
+ }));
+
+ const groupHostRowsWithSource = groupHostRows.map((row) => ({
+ ...row,
+ source: LoginMappingSource.HOST_GROUP
+ }));
+
+ const mergedRows = [...directHostRowsWithSource, ...groupHostRowsWithSource];
+
+ const hostsGrouped = groupBy(mergedRows, (r) => r.sshHostId);
+
+ return Object.values(hostsGrouped).map((hostRows) => {
const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } =
hostRows[0];
const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser);
+ const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, mappings]) => {
+ // Prefer HOST source over HOST_GROUP
+ const preferredMapping =
+ mappings.find((m) => m.source === LoginMappingSource.HOST) ||
+ mappings.find((m) => m.source === LoginMappingSource.HOST_GROUP);
- const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({
- loginUser,
- allowedPrincipals: {
- usernames: [user.username]
- }
- }));
+ return {
+ loginUser,
+ allowedPrincipals: {
+ usernames: [user.username]
+ },
+ source: preferredMapping!.source
+ };
+ });
return {
id: sshHostId,
@@ -101,20 +149,57 @@ export const sshHostDALFactory = (db: TDbClient) => {
)
.orderBy(`${TableName.SshHost}.updatedAt`, "desc");
+ // process login mappings inherited from groups that hosts are part of
+ const hostIds = unique(rows.map((r) => r.sshHostId)).filter(Boolean);
+ const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership)
+ .join(
+ TableName.SshHostLoginUser,
+ `${TableName.SshHostGroupMembership}.sshHostGroupId`,
+ `${TableName.SshHostLoginUser}.sshHostGroupId`
+ )
+ .leftJoin(
+ TableName.SshHostLoginUserMapping,
+ `${TableName.SshHostLoginUser}.id`,
+ `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
+ )
+ .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
+ .select(
+ db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership),
+ db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
+ db.ref("username").withSchema(TableName.Users)
+ )
+ .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds);
+
+ const groupedGroupMappings = groupBy(groupRows, (r) => r.sshHostId);
+
const hostsGrouped = groupBy(rows, (r) => r.sshHostId);
return Object.values(hostsGrouped).map((hostRows) => {
const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0];
+ // direct login mappings
const loginMappingGrouped = groupBy(
hostRows.filter((r) => r.loginUser),
(r) => r.loginUser
);
- const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
+ const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean)
- }
+ },
+ source: LoginMappingSource.HOST
+ }));
+
+ // group-inherited login mappings
+ const inheritedGroupRows = groupedGroupMappings[sshHostId] || [];
+ const inheritedGrouped = groupBy(inheritedGroupRows, (r) => r.loginUser);
+
+ const groupMappings = Object.entries(inheritedGrouped).map(([loginUser, entries]) => ({
+ loginUser,
+ allowedPrincipals: {
+ usernames: unique(entries.map((e) => e.username)).filter(Boolean)
+ },
+ source: LoginMappingSource.HOST_GROUP
}));
return {
@@ -124,7 +209,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
projectId,
userCertTtl,
hostCertTtl,
- loginMappings,
+ loginMappings: [...directMappings, ...groupMappings],
userSshCaId,
hostSshCaId
};
@@ -163,16 +248,50 @@ export const sshHostDALFactory = (db: TDbClient) => {
const { sshHostId: id, projectId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0];
- const loginMappingGrouped = groupBy(
+ // direct login mappings
+ const directGrouped = groupBy(
rows.filter((r) => r.loginUser),
(r) => r.loginUser
);
- const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
+ const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean)
- }
+ },
+ source: LoginMappingSource.HOST
+ }));
+
+ // group login mappings
+ const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership)
+ .join(
+ TableName.SshHostLoginUser,
+ `${TableName.SshHostGroupMembership}.sshHostGroupId`,
+ `${TableName.SshHostLoginUser}.sshHostGroupId`
+ )
+ .leftJoin(
+ TableName.SshHostLoginUserMapping,
+ `${TableName.SshHostLoginUser}.id`,
+ `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
+ )
+ .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
+ .where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId)
+ .select(
+ db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
+ db.ref("username").withSchema(TableName.Users)
+ );
+
+ const groupGrouped = groupBy(
+ groupRows.filter((r) => r.loginUser),
+ (r) => r.loginUser
+ );
+
+ const groupMappings = Object.entries(groupGrouped).map(([loginUser, entries]) => ({
+ loginUser,
+ allowedPrincipals: {
+ usernames: unique(entries.map((e) => e.username)).filter(Boolean)
+ },
+ source: LoginMappingSource.HOST_GROUP
}));
return {
@@ -182,7 +301,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
alias,
userCertTtl,
hostCertTtl,
- loginMappings,
+ loginMappings: [...directMappings, ...groupMappings],
userSshCaId,
hostSshCaId
};
diff --git a/backend/src/ee/services/ssh-host/ssh-host-fns.ts b/backend/src/ee/services/ssh-host/ssh-host-fns.ts
new file mode 100644
index 000000000..9b9ce2642
--- /dev/null
+++ b/backend/src/ee/services/ssh-host/ssh-host-fns.ts
@@ -0,0 +1,85 @@
+import { Knex } from "knex";
+
+import { ActionProjectType } from "@app/db/schemas";
+import { BadRequestError } from "@app/lib/errors";
+
+import { TCreateSshLoginMappingsDTO } from "./ssh-host-types";
+
+/**
+ * Create SSH login mappings for a given SSH host
+ * or SSH host group.
+ */
+export const createSshLoginMappings = async ({
+ sshHostId,
+ sshHostGroupId,
+ loginMappings,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ tx: outerTx
+}: TCreateSshLoginMappingsDTO) => {
+ const processCreation = async (tx: Knex) => {
+ // (dangtony98): room to optimize
+ for await (const { loginUser, allowedPrincipals } of loginMappings) {
+ const sshHostLoginUser = await sshHostLoginUserDAL.create(
+ // (dangtony98): should either pass in sshHostId or sshHostGroupId but not both
+ {
+ sshHostId,
+ sshHostGroupId,
+ loginUser
+ },
+ tx
+ );
+
+ if (allowedPrincipals.usernames.length > 0) {
+ const users = await userDAL.find(
+ {
+ $in: {
+ username: allowedPrincipals.usernames
+ }
+ },
+ { tx }
+ );
+
+ const foundUsernames = new Set(users.map((u) => u.username));
+
+ for (const uname of allowedPrincipals.usernames) {
+ if (!foundUsernames.has(uname)) {
+ throw new BadRequestError({
+ message: `Invalid username: ${uname}`
+ });
+ }
+ }
+
+ for await (const user of users) {
+ // check that each user has access to the SSH project
+ await permissionService.getUserProjectPermission({
+ userId: user.id,
+ projectId,
+ authMethod: actorAuthMethod,
+ userOrgId: actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+ }
+
+ await sshHostLoginUserMappingDAL.insertMany(
+ users.map((user) => ({
+ sshHostLoginUserId: sshHostLoginUser.id,
+ userId: user.id
+ })),
+ tx
+ );
+ }
+ }
+ };
+
+ if (outerTx) {
+ return processCreation(outerTx);
+ }
+
+ return sshHostLoginUserDAL.transaction(processCreation);
+};
diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts
index 92f1f5236..87f4862bb 100644
--- a/backend/src/ee/services/ssh-host/ssh-host-service.ts
+++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts
@@ -26,6 +26,7 @@ import {
getSshPublicKey
} from "../ssh/ssh-certificate-authority-fns";
import { SshCertType } from "../ssh/ssh-certificate-authority-types";
+import { createSshLoginMappings } from "./ssh-host-fns";
import {
TCreateSshHostDTO,
TDeleteSshHostDTO,
@@ -202,56 +203,18 @@ export const sshHostServiceFactory = ({
tx
);
- // (dangtony98): room to optimize
- for await (const { loginUser, allowedPrincipals } of loginMappings) {
- const sshHostLoginUser = await sshHostLoginUserDAL.create(
- {
- sshHostId: host.id,
- loginUser
- },
- tx
- );
-
- if (allowedPrincipals.usernames.length > 0) {
- const users = await userDAL.find(
- {
- $in: {
- username: allowedPrincipals.usernames
- }
- },
- { tx }
- );
-
- const foundUsernames = new Set(users.map((u) => u.username));
-
- for (const uname of allowedPrincipals.usernames) {
- if (!foundUsernames.has(uname)) {
- throw new BadRequestError({
- message: `Invalid username: ${uname}`
- });
- }
- }
-
- for await (const user of users) {
- // check that each user has access to the SSH project
- await permissionService.getUserProjectPermission({
- userId: user.id,
- projectId,
- authMethod: actorAuthMethod,
- userOrgId: actorOrgId,
- actionProjectType: ActionProjectType.SSH
- });
- }
-
- await sshHostLoginUserMappingDAL.insertMany(
- users.map((user) => ({
- sshHostLoginUserId: sshHostLoginUser.id,
- userId: user.id
- })),
- tx
- );
- }
- }
+ await createSshLoginMappings({
+ sshHostId: host.id,
+ loginMappings,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ tx
+ });
const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx);
if (!newSshHostWithLoginMappings) {
@@ -310,54 +273,18 @@ export const sshHostServiceFactory = ({
if (loginMappings) {
await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx);
if (loginMappings.length) {
- for await (const { loginUser, allowedPrincipals } of loginMappings) {
- const sshHostLoginUser = await sshHostLoginUserDAL.create(
- {
- sshHostId: host.id,
- loginUser
- },
- tx
- );
-
- if (allowedPrincipals.usernames.length > 0) {
- const users = await userDAL.find(
- {
- $in: {
- username: allowedPrincipals.usernames
- }
- },
- { tx }
- );
-
- const foundUsernames = new Set(users.map((u) => u.username));
-
- for (const uname of allowedPrincipals.usernames) {
- if (!foundUsernames.has(uname)) {
- throw new BadRequestError({
- message: `Invalid username: ${uname}`
- });
- }
- }
-
- for await (const user of users) {
- await permissionService.getUserProjectPermission({
- userId: user.id,
- projectId: host.projectId,
- authMethod: actorAuthMethod,
- userOrgId: actorOrgId,
- actionProjectType: ActionProjectType.SSH
- });
- }
-
- await sshHostLoginUserMappingDAL.insertMany(
- users.map((user) => ({
- sshHostLoginUserId: sshHostLoginUser.id,
- userId: user.id
- })),
- tx
- );
- }
- }
+ await createSshLoginMappings({
+ sshHostId: host.id,
+ loginMappings,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ projectId: host.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ tx
+ });
}
}
diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts
index a4826cd72..9846920b7 100644
--- a/backend/src/ee/services/ssh-host/ssh-host-types.ts
+++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts
@@ -1,18 +1,32 @@
+import { Knex } from "knex";
+
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
+import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal";
+import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal";
import { TProjectPermission } from "@app/lib/types";
+import { ActorAuthMethod } from "@app/services/auth/auth-type";
+import { TUserDALFactory } from "@app/services/user/user-dal";
export type TListSshHostsDTO = Omit;
+export type TLoginMapping = {
+ loginUser: string;
+ allowedPrincipals: {
+ usernames: string[];
+ };
+};
+
+export enum LoginMappingSource {
+ HOST = "host",
+ HOST_GROUP = "hostGroup"
+}
+
export type TCreateSshHostDTO = {
hostname: string;
alias?: string;
userCertTtl: string;
hostCertTtl: string;
- loginMappings: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings: TLoginMapping[];
userSshCaId?: string;
hostSshCaId?: string;
} & TProjectPermission;
@@ -23,12 +37,7 @@ export type TUpdateSshHostDTO = {
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
- loginMappings?: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings?: TLoginMapping[];
} & Omit;
export type TGetSshHostDTO = {
@@ -48,3 +57,19 @@ export type TIssueSshHostHostCertDTO = {
sshHostId: string;
publicKey: string;
} & Omit;
+
+type BaseCreateSshLoginMappingsDTO = {
+ loginMappings: TLoginMapping[];
+ sshHostLoginUserDAL: Pick;
+ sshHostLoginUserMappingDAL: Pick;
+ userDAL: Pick;
+ permissionService: Pick;
+ projectId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ tx?: Knex;
+};
+
+export type TCreateSshLoginMappingsDTO =
+ | (BaseCreateSshLoginMappingsDTO & { sshHostId: string; sshHostGroupId?: undefined })
+ | (BaseCreateSshLoginMappingsDTO & { sshHostGroupId: string; sshHostId?: undefined });
diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index dfb23a6c3..10454ab9b 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -48,6 +48,8 @@ export enum ApiDocsTags {
SshCertificates = "SSH Certificates",
SshCertificateAuthorities = "SSH Certificate Authorities",
SshCertificateTemplates = "SSH Certificate Templates",
+ SshHosts = "SSH Hosts",
+ SshHostGroups = "SSH Host Groups",
KmsKeys = "KMS Keys",
KmsEncryption = "KMS Encryption",
KmsSigning = "KMS Signing"
@@ -568,6 +570,9 @@ export const PROJECTS = {
LIST_SSH_HOSTS: {
projectId: "The ID of the project to list SSH hosts for."
},
+ LIST_SSH_HOST_GROUPS: {
+ projectId: "The ID of the project to list SSH host groups for."
+ },
LIST_SSH_CERTIFICATES: {
projectId: "The ID of the project to list SSH certificates for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.",
@@ -1382,6 +1387,40 @@ export const SSH_CERTIFICATE_TEMPLATES = {
}
};
+export const SSH_HOST_GROUPS = {
+ GET: {
+ sshHostGroupId: "The ID of the SSH host group to get.",
+ filter: "The filter to apply to the SSH hosts in the SSH host group."
+ },
+ CREATE: {
+ projectId: "The ID of the project to create the SSH host group in.",
+ name: "The name of the SSH host group.",
+ loginMappings:
+ "A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project."
+ },
+ UPDATE: {
+ sshHostGroupId: "The ID of the SSH host group to update.",
+ name: "The name of the SSH host group to update to.",
+ loginMappings:
+ "A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project."
+ },
+ DELETE: {
+ sshHostGroupId: "The ID of the SSH host group to delete."
+ },
+ LIST_HOSTS: {
+ offset: "The offset to start from. If you enter 10, it will start from the 10th host",
+ limit: "The number of hosts to return."
+ },
+ ADD_HOST: {
+ sshHostGroupId: "The ID of the SSH host group to add the host to.",
+ hostId: "The ID of the SSH host to add to the SSH host group."
+ },
+ DELETE_HOST: {
+ sshHostGroupId: "The ID of the SSH host group to delete the host from.",
+ hostId: "The ID of the SSH host to delete from the SSH host group."
+ }
+};
+
export const SSH_HOSTS = {
GET: {
sshHostId: "The ID of the SSH host to get."
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index fb9c2fc32..e10f49794 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -103,6 +103,9 @@ import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal";
import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service";
import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal";
+import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
+import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal";
+import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service";
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
import { TKeyStoreFactory } from "@app/keystore/keystore";
@@ -403,6 +406,8 @@ export const registerRoutes = async (
const sshHostDAL = sshHostDALFactory(db);
const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db);
const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db);
+ const sshHostGroupDAL = sshHostGroupDALFactory(db);
+ const sshHostGroupMembershipDAL = sshHostGroupMembershipDALFactory(db);
const kmsDAL = kmskeyDALFactory(db);
const internalKmsDAL = internalKmsDALFactory(db);
@@ -634,6 +639,7 @@ export const registerRoutes = async (
tokenService,
orgDAL,
totpService,
+ orgMembershipDAL,
auditLogService
});
const passwordService = authPaswordServiceFactory({
@@ -871,6 +877,18 @@ export const registerRoutes = async (
kmsService
});
+ const sshHostGroupService = sshHostGroupServiceFactory({
+ projectDAL,
+ sshHostDAL,
+ sshHostGroupDAL,
+ sshHostGroupMembershipDAL,
+ sshHostLoginUserDAL,
+ sshHostLoginUserMappingDAL,
+ userDAL,
+ permissionService,
+ licenseService
+ });
+
const certificateAuthorityService = certificateAuthorityServiceFactory({
certificateAuthorityDAL,
certificateAuthorityCertDAL,
@@ -1040,6 +1058,7 @@ export const registerRoutes = async (
sshCertificateDAL,
sshCertificateTemplateDAL,
sshHostDAL,
+ sshHostGroupDAL,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL,
keyStore,
@@ -1698,6 +1717,7 @@ export const registerRoutes = async (
sshCertificateAuthority: sshCertificateAuthorityService,
sshCertificateTemplate: sshCertificateTemplateService,
sshHost: sshHostService,
+ sshHostGroup: sshHostGroupService,
certificateAuthority: certificateAuthorityService,
certificateTemplate: certificateTemplateService,
certificateAuthorityCrl: certificateAuthorityCrlService,
diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts
index f7a1b973a..b6b3cb8aa 100644
--- a/backend/src/server/routes/v1/sso-router.ts
+++ b/backend/src/server/routes/v1/sso-router.ts
@@ -23,6 +23,7 @@ import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github";
import { authRateLimit } from "@app/server/config/rateLimiter";
import { AuthMethod } from "@app/services/auth/auth-type";
import { OrgAuthMethod } from "@app/services/org/org-types";
+import { getServerCfg } from "@app/services/super-admin/super-admin-service";
export const registerSsoRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
@@ -342,8 +343,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
}`
);
}
+
+ const serverCfg = await getServerCfg();
return res.redirect(
- `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`
+ `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${
+ serverCfg.defaultAuthOrgId && !appCfg.isCloud ? `&defaultOrgAllowed=true` : ""
+ }`
);
}
});
diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts
index f7540591e..a223004a9 100644
--- a/backend/src/server/routes/v2/project-router.ts
+++ b/backend/src/server/routes/v2/project-router.ts
@@ -14,6 +14,8 @@ import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-s
import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema";
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
+import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types";
+import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema";
import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
@@ -631,7 +633,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
200: z.object({
hosts: z.array(
sanitizedSshHost.extend({
- loginMappings: z.array(loginMappingSchema)
+ loginMappings: loginMappingSchema
+ .extend({
+ source: z.nativeEnum(LoginMappingSource)
+ })
+ .array()
})
)
})
@@ -650,4 +656,39 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
return { hosts };
}
});
+
+ server.route({
+ method: "GET",
+ url: "/:projectId/ssh-host-groups",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ params: z.object({
+ projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOST_GROUPS.projectId)
+ }),
+ response: {
+ 200: z.object({
+ groups: z.array(
+ sanitizedSshHostGroup.extend({
+ loginMappings: loginMappingSchema.array(),
+ hostCount: z.number()
+ })
+ )
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const groups = await server.services.project.listProjectSshHostGroups({
+ actorId: req.permission.id,
+ actorOrgId: req.permission.orgId,
+ actorAuthMethod: req.permission.authMethod,
+ actor: req.permission.type,
+ projectId: req.params.projectId
+ });
+
+ return { groups };
+ }
+ });
};
diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts
index d9196dc88..552253cde 100644
--- a/backend/src/server/routes/v3/signup-router.ts
+++ b/backend/src/server/routes/v3/signup-router.ts
@@ -88,24 +88,41 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
rateLimit: authRateLimit
},
schema: {
- body: z.object({
- email: z.string().trim(),
- firstName: z.string().trim(),
- lastName: z.string().trim().optional(),
- protectedKey: z.string().trim(),
- protectedKeyIV: z.string().trim(),
- protectedKeyTag: z.string().trim(),
- publicKey: z.string().trim(),
- encryptedPrivateKey: z.string().trim(),
- encryptedPrivateKeyIV: z.string().trim(),
- encryptedPrivateKeyTag: z.string().trim(),
- salt: z.string().trim(),
- verifier: z.string().trim(),
- organizationName: GenericResourceNameSchema,
- providerAuthToken: z.string().trim().optional().nullish(),
- attributionSource: z.string().trim().optional(),
- password: z.string()
- }),
+ body: z
+ .object({
+ email: z.string().trim(),
+ firstName: z.string().trim(),
+ lastName: z.string().trim().optional(),
+ protectedKey: z.string().trim(),
+ protectedKeyIV: z.string().trim(),
+ protectedKeyTag: z.string().trim(),
+ publicKey: z.string().trim(),
+ encryptedPrivateKey: z.string().trim(),
+ encryptedPrivateKeyIV: z.string().trim(),
+ encryptedPrivateKeyTag: z.string().trim(),
+ salt: z.string().trim(),
+ verifier: z.string().trim(),
+ providerAuthToken: z.string().trim().optional().nullish(),
+ attributionSource: z.string().trim().optional(),
+ password: z.string()
+ })
+ .and(
+ z.preprocess(
+ (data) => {
+ if (typeof data === "object" && data && "useDefaultOrg" in data === false) {
+ return { ...data, useDefaultOrg: false };
+ }
+ return data;
+ },
+ z.discriminatedUnion("useDefaultOrg", [
+ z.object({ useDefaultOrg: z.literal(true) }),
+ z.object({
+ useDefaultOrg: z.literal(false),
+ organizationName: GenericResourceNameSchema
+ })
+ ])
+ )
+ ),
response: {
200: z.object({
message: z.string(),
diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts
index d1b0a550d..14aa8f038 100644
--- a/backend/src/services/auth/auth-login-service.ts
+++ b/backend/src/services/auth/auth-login-service.ts
@@ -2,7 +2,7 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Knex } from "knex";
-import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas";
+import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers, UserDeviceSchema } from "@app/db/schemas";
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
@@ -20,6 +20,8 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { TOrgDALFactory } from "../org/org-dal";
+import { getDefaultOrgMembershipRole } from "../org/org-role-fns";
+import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { LoginMethod } from "../super-admin/super-admin-types";
import { TTotpServiceFactory } from "../totp/totp-service";
@@ -48,6 +50,7 @@ type TAuthLoginServiceFactoryDep = {
smtpService: TSmtpService;
totpService: Pick;
auditLogService: Pick;
+ orgMembershipDAL: TOrgMembershipDALFactory;
};
export type TAuthLoginFactory = ReturnType;
@@ -56,6 +59,7 @@ export const authLoginServiceFactory = ({
tokenService,
smtpService,
orgDAL,
+ orgMembershipDAL,
totpService,
auditLogService
}: TAuthLoginServiceFactoryDep) => {
@@ -719,6 +723,35 @@ export const authLoginServiceFactory = ({
authMethods: [authMethod],
isGhost: false
});
+
+ if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
+ let orgId = "";
+ const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
+ if (!defaultOrg) {
+ throw new BadRequestError({
+ message: `Failed to find default organization with ID ${serverCfg.defaultAuthOrgId}`
+ });
+ }
+ orgId = defaultOrg.id;
+ const [orgMembership] = await orgDAL.findMembership({
+ [`${TableName.OrgMembership}.userId` as "userId"]: user.id,
+ [`${TableName.OrgMembership}.orgId` as "id"]: orgId
+ });
+
+ if (!orgMembership) {
+ const { role, roleId } = await getDefaultOrgMembershipRole(defaultOrg.defaultMembershipRole);
+
+ await orgMembershipDAL.create({
+ userId: user.id,
+ inviteEmail: email,
+ orgId,
+ role,
+ roleId,
+ status: OrgMembershipStatus.Accepted,
+ isActive: true
+ });
+ }
+ }
} else {
const isLinkingRequired = !user?.authMethods?.includes(authMethod);
if (isLinkingRequired) {
diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts
index 58ba9186e..4d8c98205 100644
--- a/backend/src/services/auth/auth-signup-service.ts
+++ b/backend/src/services/auth/auth-signup-service.ts
@@ -9,7 +9,7 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
-import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { getMinExpiresIn } from "@app/lib/fn";
import { isDisposableEmail } from "@app/lib/validator";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
@@ -150,7 +150,8 @@ export const authSignupServiceFactory = ({
encryptedPrivateKeyTag,
ip,
userAgent,
- authorization
+ authorization,
+ useDefaultOrg
}: TCompleteAccountSignupDTO) => {
const appCfg = getConfig();
const serverCfg = await getServerCfg();
@@ -293,15 +294,24 @@ export const authSignupServiceFactory = ({
});
if (!organizationId) {
- const newOrganization = await orgService.createOrganization({
- userId: user.id,
- userEmail: user.email ?? user.username,
- orgName: organizationName
- });
+ let orgId = "";
+ if (useDefaultOrg && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
+ const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
+ if (!defaultOrg) throw new BadRequestError({ message: "Failed to find default organization" });
+ orgId = defaultOrg.id;
+ } else {
+ if (!organizationName) throw new BadRequestError({ message: "Organization name is required" });
+ const newOrganization = await orgService.createOrganization({
+ userId: user.id,
+ userEmail: user.email ?? user.username,
+ orgName: organizationName
+ });
- if (!newOrganization) throw new Error("Failed to create organization");
+ if (!newOrganization) throw new Error("Failed to create organization");
+ orgId = newOrganization.id;
+ }
- organizationId = newOrganization.id;
+ organizationId = orgId;
}
const updatedMembersips = await orgDAL.updateMembership(
diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts
index 3308b9d12..8bbf302c5 100644
--- a/backend/src/services/auth/auth-signup-type.ts
+++ b/backend/src/services/auth/auth-signup-type.ts
@@ -12,12 +12,13 @@ export type TCompleteAccountSignupDTO = {
encryptedPrivateKeyTag: string;
salt: string;
verifier: string;
- organizationName: string;
+ organizationName?: string;
providerAuthToken?: string | null;
attributionSource?: string | undefined;
ip: string;
userAgent: string;
authorization: string;
+ useDefaultOrg?: boolean;
};
export type TCompleteAccountInviteDTO = {
diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts
index 73a439055..8e60252ba 100644
--- a/backend/src/services/project/project-service.ts
+++ b/backend/src/services/project/project-service.ts
@@ -25,6 +25,7 @@ import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/s
import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
+import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
@@ -153,12 +154,12 @@ type TProjectServiceFactoryDep = {
sshCertificateDAL: Pick;
sshCertificateTemplateDAL: Pick;
sshHostDAL: Pick;
+ sshHostGroupDAL: Pick;
permissionService: TPermissionServiceFactory;
orgService: Pick;
licenseService: Pick;
queueService: Pick;
smtpService: Pick;
-
orgDAL: Pick;
keyStore: Pick;
projectBotDAL: Pick;
@@ -210,6 +211,7 @@ export const projectServiceFactory = ({
sshCertificateDAL,
sshCertificateTemplateDAL,
sshHostDAL,
+ sshHostGroupDAL,
keyStore,
kmsService,
projectBotDAL,
@@ -1162,6 +1164,32 @@ export const projectServiceFactory = ({
return allowedHosts;
};
+ /**
+ * Return list of SSH host groups for project
+ */
+ const listProjectSshHostGroups = async ({
+ actorId,
+ actorOrgId,
+ actorAuthMethod,
+ actor,
+ projectId
+ }: TListProjectSshHostsDTO) => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.SSH
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
+
+ const sshHostGroups = await sshHostGroupDAL.findSshHostGroupsWithLoginMappings(projectId);
+
+ return sshHostGroups;
+ };
+
/**
* Return list of SSH certificates for project
*/
@@ -1892,6 +1920,7 @@ export const projectServiceFactory = ({
listProjectCertificateTemplates,
listProjectSshCas,
listProjectSshHosts,
+ listProjectSshHostGroups,
listProjectSshCertificates,
listProjectSshCertificateTemplates,
updateVersionLimit,
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/create-permanent.mdx
similarity index 100%
rename from docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx
rename to docs/api-reference/endpoints/identity-specific-privilege/v1/create-permanent.mdx
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/create-temporary.mdx
similarity index 100%
rename from docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx
rename to docs/api-reference/endpoints/identity-specific-privilege/v1/create-temporary.mdx
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/delete.mdx
similarity index 100%
rename from docs/api-reference/endpoints/identity-specific-privilege/delete.mdx
rename to docs/api-reference/endpoints/identity-specific-privilege/v1/delete.mdx
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx
similarity index 70%
rename from docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx
rename to docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx
index a6ec27217..cdf3ba5ea 100644
--- a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx
@@ -1,4 +1,4 @@
---
-title: "Find By Privilege Slug"
+title: "Find By Slug"
openapi: "GET /api/v1/additional-privilege/identity/{privilegeSlug}"
---
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/list.mdx
similarity index 100%
rename from docs/api-reference/endpoints/identity-specific-privilege/list.mdx
rename to docs/api-reference/endpoints/identity-specific-privilege/v1/list.mdx
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/update.mdx
similarity index 100%
rename from docs/api-reference/endpoints/identity-specific-privilege/update.mdx
rename to docs/api-reference/endpoints/identity-specific-privilege/v1/update.mdx
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx
new file mode 100644
index 000000000..7ff358b40
--- /dev/null
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v2/identity-project-additional-privilege"
+---
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx
new file mode 100644
index 000000000..68d6596e3
--- /dev/null
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v2/identity-project-additional-privilege/{id}"
+---
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx
new file mode 100644
index 000000000..d9d40a473
--- /dev/null
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx
@@ -0,0 +1,4 @@
+---
+title: "Find By ID"
+openapi: "GET /api/v2/identity-project-additional-privilege/{id}"
+---
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx
new file mode 100644
index 000000000..290e332ed
--- /dev/null
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx
@@ -0,0 +1,4 @@
+---
+title: "Find By Slug"
+openapi: "GET /api/v2/identity-project-additional-privilege/slug/{privilegeSlug}"
+---
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx
new file mode 100644
index 000000000..33a4673d9
--- /dev/null
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/identity-project-additional-privilege"
+---
diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx
new file mode 100644
index 000000000..b0dd9b4b2
--- /dev/null
+++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v2/identity-project-additional-privilege/{id}"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/add-host.mdx b/docs/api-reference/endpoints/ssh/groups/add-host.mdx
new file mode 100644
index 000000000..9f903eccd
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/add-host.mdx
@@ -0,0 +1,4 @@
+---
+title: "Add Host"
+openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/create.mdx b/docs/api-reference/endpoints/ssh/groups/create.mdx
new file mode 100644
index 000000000..be4755d8e
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v1/ssh/host-groups"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/delete.mdx b/docs/api-reference/endpoints/ssh/groups/delete.mdx
new file mode 100644
index 000000000..19205f2eb
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx
new file mode 100644
index 000000000..2db1fd11f
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx
@@ -0,0 +1,4 @@
+---
+title: "List Hosts"
+openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/list.mdx b/docs/api-reference/endpoints/ssh/groups/list.mdx
new file mode 100644
index 000000000..089ab4096
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/read.mdx b/docs/api-reference/endpoints/ssh/groups/read.mdx
new file mode 100644
index 000000000..060a75bce
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/read.mdx
@@ -0,0 +1,4 @@
+---
+title: "Retrieve"
+openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/remove-host.mdx b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx
new file mode 100644
index 000000000..6933e5c9f
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx
@@ -0,0 +1,4 @@
+---
+title: "Remove Host"
+openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}"
+---
diff --git a/docs/api-reference/endpoints/ssh/groups/update.mdx b/docs/api-reference/endpoints/ssh/groups/update.mdx
new file mode 100644
index 000000000..3e23bf4f9
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/groups/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/create.mdx b/docs/api-reference/endpoints/ssh/hosts/create.mdx
new file mode 100644
index 000000000..5860b6b49
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/create.mdx
@@ -0,0 +1,4 @@
+---
+title: "Create"
+openapi: "POST /api/v1/ssh/hosts"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/delete.mdx b/docs/api-reference/endpoints/ssh/hosts/delete.mdx
new file mode 100644
index 000000000..c52129b33
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/delete.mdx
@@ -0,0 +1,4 @@
+---
+title: "Delete"
+openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx
new file mode 100644
index 000000000..bb843e466
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx
@@ -0,0 +1,4 @@
+---
+title: "Issue Host Certificate"
+openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx
new file mode 100644
index 000000000..a16e03a65
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx
@@ -0,0 +1,4 @@
+---
+title: "Issue User Certificate"
+openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/list-my.mdx b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx
new file mode 100644
index 000000000..2b7ab51c0
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx
@@ -0,0 +1,4 @@
+---
+title: "List My Hosts"
+openapi: "GET /api/v1/ssh/hosts/"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/list.mdx b/docs/api-reference/endpoints/ssh/hosts/list.mdx
new file mode 100644
index 000000000..3f61123e0
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/list.mdx
@@ -0,0 +1,4 @@
+---
+title: "List"
+openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx
new file mode 100644
index 000000000..15ca53e9d
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx
@@ -0,0 +1,4 @@
+---
+title: "Read Host CA Public Key"
+openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx
new file mode 100644
index 000000000..a6f31e93f
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx
@@ -0,0 +1,4 @@
+---
+title: "Read User CA Public Key"
+openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/read.mdx b/docs/api-reference/endpoints/ssh/hosts/read.mdx
new file mode 100644
index 000000000..112296a5a
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/read.mdx
@@ -0,0 +1,4 @@
+---
+title: "Retrieve"
+openapi: "GET /api/v1/ssh/hosts/{sshHostId}"
+---
diff --git a/docs/api-reference/endpoints/ssh/hosts/update.mdx b/docs/api-reference/endpoints/ssh/hosts/update.mdx
new file mode 100644
index 000000000..d5555b813
--- /dev/null
+++ b/docs/api-reference/endpoints/ssh/hosts/update.mdx
@@ -0,0 +1,4 @@
+---
+title: "Update"
+openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}"
+---
diff --git a/docs/documentation/platform/ssh/host-groups.mdx b/docs/documentation/platform/ssh/host-groups.mdx
new file mode 100644
index 000000000..877291183
--- /dev/null
+++ b/docs/documentation/platform/ssh/host-groups.mdx
@@ -0,0 +1,56 @@
+---
+title: "Infisical SSH"
+sidebarTitle: "Host Groups"
+description: "Learn how to organize SSH hosts into groups and manage access policies at scale."
+---
+
+## Concept
+
+Infisical SSH lets you configure host groups to organize and manage multiple SSH hosts with shared access configuration.
+These host groups can be created based on environments (`development`, `staging`, `production`), geographical regions (`us-east`, `eu-west`, `ap-northeast`), or functions (`web-servers`, `database-servers`, `worker-nodes`) to streamline access management across your infrastructure.
+
+Using a host group, you can define login mappings at the group level and have them be applied to all hosts assigned to that group. For example, you can specify that `john@example.com` can login as `ubuntu` on all hosts assigned to the `production` host group.
+
+## Workflow
+
+The typical workflow for using Infisical SSH with host groups consists of the following steps:
+
+1. The administrator creates host groups based on logical groupings (environments, regions, functions, etc.).
+2. The administrator configures login mappings at the host group level to define access policies.
+3. The administrator registers remote hosts with Infisical using the Infisical CLI via the `infisical ssh add-host` command and assigns them to appropriate host groups either using the `--host-group` flag or by adding them to the host group via UI.
+4. User(s) access the remote hosts using the Infisical CLI via the `infisical ssh connect` command, with access determined by the login mappings defined at both host and host group levels.
+
+## Admin Guide for Configuring Host Groups
+
+In the following steps, we'll walk through how to create and configure Host Groups in Infisical SSH, and how to add hosts to these groups.
+
+
+
+ 1.1. Navigate to your Infisical SSH project and select the **Hosts** tab.
+
+ 1.2. Click **Add Group** in the **Host Groups** section to create a new group.
+
+ Enter a name (e.g., `production-servers` or `tokyo-region`) and login mapping(s) for the host group.
+
+ A login mapping for a host group applies to all hosts assigned to the group and dictates what user(s) will be allowed access to the remote hosts
+ in that group under specific login user(s); in the allowed principals, you should select user(s) part of the Infisical SSH project that will
+ be allowed to login to the remote host as the login user.
+
+ For instance, if you add a mapping to a host group with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to any remote host that is part of the group as `ec2-user` which is a system user that
+ exists on the remote host(s).
+
+ 
+ 
+
+ 1.3. Click **Add** to create the host group.
+
+
+
+
+ After creating the host group, you can assign a host to it from inside the host group page in the **SSH Hosts** section. Generally, this is where you'll manage the hosts in a group.
+
+ 
+ 
+
+
+
diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh/overview.mdx
similarity index 99%
rename from docs/documentation/platform/ssh.mdx
rename to docs/documentation/platform/ssh/overview.mdx
index 2bc433f75..e71eeabe1 100644
--- a/docs/documentation/platform/ssh.mdx
+++ b/docs/documentation/platform/ssh/overview.mdx
@@ -1,6 +1,6 @@
---
title: "Infisical SSH"
-sidebarTitle: "Infisical SSH"
+sidebarTitle: "Overview"
description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates."
---
diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-1.png b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png
new file mode 100644
index 000000000..be023335f
Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png differ
diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-2.png b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png
new file mode 100644
index 000000000..0507d6e99
Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png differ
diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-1.png b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png
new file mode 100644
index 000000000..de8568f61
Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png differ
diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-2.png b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png
new file mode 100644
index 000000000..79143c4da
Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png differ
diff --git a/docs/mint.json b/docs/mint.json
index 0040e739b..a25a70124 100644
--- a/docs/mint.json
+++ b/docs/mint.json
@@ -118,7 +118,13 @@
"documentation/platform/pki/alerting"
]
},
- "documentation/platform/ssh",
+ {
+ "group": "Infisical SSH",
+ "pages": [
+ "documentation/platform/ssh/overview",
+ "documentation/platform/ssh/host-groups"
+ ]
+ },
{
"group": "Key Management (KMS)",
"pages": [
@@ -949,12 +955,28 @@
{
"group": "Identity Specific Privilege",
"pages": [
- "api-reference/endpoints/identity-specific-privilege/create-permanent",
- "api-reference/endpoints/identity-specific-privilege/create-temporary",
- "api-reference/endpoints/identity-specific-privilege/update",
- "api-reference/endpoints/identity-specific-privilege/delete",
- "api-reference/endpoints/identity-specific-privilege/find-by-slug",
- "api-reference/endpoints/identity-specific-privilege/list"
+ {
+ "group": "V1 (Legacy)",
+ "pages": [
+ "api-reference/endpoints/identity-specific-privilege/v1/create-permanent",
+ "api-reference/endpoints/identity-specific-privilege/v1/create-temporary",
+ "api-reference/endpoints/identity-specific-privilege/v1/update",
+ "api-reference/endpoints/identity-specific-privilege/v1/delete",
+ "api-reference/endpoints/identity-specific-privilege/v1/find-by-slug",
+ "api-reference/endpoints/identity-specific-privilege/v1/list"
+ ]
+ },
+ {
+ "group": "V2",
+ "pages": [
+ "api-reference/endpoints/identity-specific-privilege/v2/create",
+ "api-reference/endpoints/identity-specific-privilege/v2/update",
+ "api-reference/endpoints/identity-specific-privilege/v2/delete",
+ "api-reference/endpoints/identity-specific-privilege/v2/list",
+ "api-reference/endpoints/identity-specific-privilege/v2/find-by-id",
+ "api-reference/endpoints/identity-specific-privilege/v2/find-by-slug"
+ ]
+ }
]
},
{
@@ -1471,6 +1493,34 @@
{
"group": "Infisical SSH",
"pages": [
+ {
+ "group": "Hosts",
+ "pages": [
+ "api-reference/endpoints/ssh/hosts/list-my",
+ "api-reference/endpoints/ssh/hosts/list",
+ "api-reference/endpoints/ssh/hosts/create",
+ "api-reference/endpoints/ssh/hosts/read",
+ "api-reference/endpoints/ssh/hosts/update",
+ "api-reference/endpoints/ssh/hosts/delete",
+ "api-reference/endpoints/ssh/hosts/issue-host-cert",
+ "api-reference/endpoints/ssh/hosts/issue-user-cert",
+ "api-reference/endpoints/ssh/hosts/read-user-ca-pk",
+ "api-reference/endpoints/ssh/hosts/read-host-ca-pk"
+ ]
+ },
+ {
+ "group": "Host Groups",
+ "pages": [
+ "api-reference/endpoints/ssh/groups/list",
+ "api-reference/endpoints/ssh/groups/create",
+ "api-reference/endpoints/ssh/groups/read",
+ "api-reference/endpoints/ssh/groups/update",
+ "api-reference/endpoints/ssh/groups/delete",
+ "api-reference/endpoints/ssh/groups/add-host",
+ "api-reference/endpoints/ssh/groups/list-hosts",
+ "api-reference/endpoints/ssh/groups/remove-host"
+ ]
+ },
{
"group": "Certificates",
"pages": [
diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts
index f44004d78..5efe7ca69 100644
--- a/frontend/src/const/routes.ts
+++ b/frontend/src/const/routes.ts
@@ -304,6 +304,10 @@ export const ROUTE_PATHS = Object.freeze({
SshCaByIDPage: setRoute(
"/ssh/$projectId/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId"
+ ),
+ SshHostGroupDetailsByIDPage: setRoute(
+ "/ssh/$projectId/ssh-host-groups/$sshHostGroupId",
+ "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId"
)
},
Public: {
diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts
index f7ba69e17..71193dd6e 100644
--- a/frontend/src/context/ProjectPermissionContext/types.ts
+++ b/frontend/src/context/ProjectPermissionContext/types.ts
@@ -175,6 +175,7 @@ export enum ProjectPermissionSub {
SshCertificateTemplates = "ssh-certificate-templates",
SshCertificates = "ssh-certificates",
SshHosts = "ssh-hosts",
+ SshHostGroups = "ssh-host-groups",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
@@ -272,6 +273,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
+ | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups]
| [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts
index 32610c28d..cbd20b643 100644
--- a/frontend/src/hooks/api/auth/types.ts
+++ b/frontend/src/hooks/api/auth/types.ts
@@ -107,6 +107,7 @@ export type CompleteAccountSignupDTO = CompleteAccountDTO & {
providerAuthToken?: string;
attributionSource?: string;
organizationName: string;
+ useDefaultOrg?: boolean;
};
export type VerifySignupInviteDTO = {
diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx
index 2a80c6174..4bc06f7e3 100644
--- a/frontend/src/hooks/api/index.tsx
+++ b/frontend/src/hooks/api/index.tsx
@@ -44,6 +44,7 @@ export * from "./serviceTokens";
export * from "./sshCa";
export * from "./sshCertificateTemplates";
export * from "./sshHost";
+export * from "./sshHostGroup";
export * from "./ssoConfig";
export * from "./subscriptions";
export * from "./tags";
diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts
index ebeb5130a..e92ddeaa8 100644
--- a/frontend/src/hooks/api/sshHost/types.ts
+++ b/frontend/src/hooks/api/sshHost/types.ts
@@ -1,3 +1,16 @@
+export enum LoginMappingSource {
+ HOST = "host",
+ HOST_GROUP = "hostGroup"
+}
+
+export type TLoginMapping = {
+ loginUser: string;
+ allowedPrincipals: {
+ usernames: string[];
+ };
+ source: LoginMappingSource;
+};
+
export type TSshHost = {
id: string;
projectId: string;
@@ -5,26 +18,15 @@ export type TSshHost = {
alias: string | null;
userCertTtl: string;
hostCertTtl: string;
- loginMappings: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings: TLoginMapping[];
};
-
export type TCreateSshHostDTO = {
projectId: string;
hostname: string;
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
- loginMappings: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings: Omit[];
};
export type TUpdateSshHostDTO = {
@@ -33,12 +35,7 @@ export type TUpdateSshHostDTO = {
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
- loginMappings?: {
- loginUser: string;
- allowedPrincipals: {
- usernames: string[];
- };
- }[];
+ loginMappings?: Omit[];
};
export type TDeleteSshHostDTO = {
diff --git a/frontend/src/hooks/api/sshHostGroup/index.tsx b/frontend/src/hooks/api/sshHostGroup/index.tsx
new file mode 100644
index 000000000..b131e1611
--- /dev/null
+++ b/frontend/src/hooks/api/sshHostGroup/index.tsx
@@ -0,0 +1,8 @@
+export {
+ useAddHostToSshHostGroup,
+ useCreateSshHostGroup,
+ useDeleteSshHostGroup,
+ useRemoveHostFromSshHostGroup,
+ useUpdateSshHostGroup
+} from "./mutations";
+export { useGetSshHostGroupById, useListSshHostGroupHosts } from "./queries";
diff --git a/frontend/src/hooks/api/sshHostGroup/mutations.tsx b/frontend/src/hooks/api/sshHostGroup/mutations.tsx
new file mode 100644
index 000000000..b75cff187
--- /dev/null
+++ b/frontend/src/hooks/api/sshHostGroup/mutations.tsx
@@ -0,0 +1,105 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import { workspaceKeys } from "../workspace/query-keys";
+import { sshHostGroupKeys } from "./queries";
+import {
+ TCreateSshHostGroupDTO,
+ TDeleteSshHostGroupDTO,
+ TSshHostGroup,
+ TUpdateSshHostGroupDTO
+} from "./types";
+
+export const useCreateSshHostGroup = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (body) => {
+ const { data: hostGroup } = await apiRequest.post("/api/v1/ssh/host-groups", body);
+ return hostGroup;
+ },
+ onSuccess: ({ projectId, id }) => {
+ queryClient.invalidateQueries({
+ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
+ });
+ queryClient.invalidateQueries({
+ queryKey: sshHostGroupKeys.getSshHostGroupById(id)
+ });
+ }
+ });
+};
+
+export const useUpdateSshHostGroup = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ sshHostGroupId, ...body }) => {
+ const { data: hostGroup } = await apiRequest.patch(
+ `/api/v1/ssh/host-groups/${sshHostGroupId}`,
+ body
+ );
+ return hostGroup;
+ },
+ onSuccess: ({ projectId }, { sshHostGroupId }) => {
+ queryClient.invalidateQueries({
+ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
+ });
+ queryClient.invalidateQueries({
+ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId)
+ });
+ queryClient.invalidateQueries({
+ queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId)
+ });
+ }
+ });
+};
+
+export const useDeleteSshHostGroup = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ sshHostGroupId }) => {
+ const { data: hostGroup } = await apiRequest.delete(
+ `/api/v1/ssh/host-groups/${sshHostGroupId}`
+ );
+ return hostGroup;
+ },
+ onSuccess: ({ projectId }, { sshHostGroupId }) => {
+ queryClient.invalidateQueries({
+ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
+ });
+ queryClient.invalidateQueries({
+ queryKey: workspaceKeys.getWorkspaceSshHosts(projectId)
+ });
+ queryClient.invalidateQueries({
+ queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId)
+ });
+ }
+ });
+};
+
+export const useAddHostToSshHostGroup = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ sshHostGroupId, sshHostId }) => {
+ await apiRequest.post(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
+ },
+ onSuccess: (_, { sshHostGroupId }) => {
+ queryClient.invalidateQueries({
+ queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
+ });
+ }
+ });
+};
+
+export const useRemoveHostFromSshHostGroup = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ sshHostGroupId, sshHostId }) => {
+ await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
+ },
+ onSuccess: (_, { sshHostGroupId }) => {
+ queryClient.invalidateQueries({
+ queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
+ });
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/sshHostGroup/queries.tsx b/frontend/src/hooks/api/sshHostGroup/queries.tsx
new file mode 100644
index 000000000..8be42dd7b
--- /dev/null
+++ b/frontend/src/hooks/api/sshHostGroup/queries.tsx
@@ -0,0 +1,60 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import { EHostGroupMembershipFilter, TListSshHostGroupHostsResponse, TSshHostGroup } from "./types";
+
+export const sshHostGroupKeys = {
+ getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"],
+ allSshHostGroupHosts: () => ["ssh-host-group-hosts"] as const,
+ forSshHostGroupHosts: (sshHostGroupId: string) =>
+ [...sshHostGroupKeys.allSshHostGroupHosts(), sshHostGroupId] as const,
+ specificSshHostGroupHosts: ({
+ sshHostGroupId,
+ filter
+ }: {
+ sshHostGroupId: string;
+ filter?: EHostGroupMembershipFilter;
+ }) => [...sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId), { filter }] as const
+};
+
+export const useGetSshHostGroupById = (sshHostGroupId: string) => {
+ return useQuery({
+ queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId),
+ queryFn: async () => {
+ const { data: sshHostGroup } = await apiRequest.get(
+ `/api/v1/ssh/host-groups/${sshHostGroupId}`
+ );
+ return sshHostGroup;
+ },
+ enabled: Boolean(sshHostGroupId)
+ });
+};
+
+export const useListSshHostGroupHosts = ({
+ sshHostGroupId,
+ filter
+}: {
+ sshHostGroupId: string;
+ filter?: EHostGroupMembershipFilter;
+}) => {
+ return useQuery({
+ queryKey: sshHostGroupKeys.specificSshHostGroupHosts({ sshHostGroupId, filter }),
+ queryFn: async () => {
+ const params = new URLSearchParams({
+ ...(filter ? { filter } : {})
+ });
+
+ const { data } = await apiRequest.get(
+ `/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`,
+ {
+ params
+ }
+ );
+ return data;
+ },
+ enabled: Boolean(sshHostGroupId),
+ staleTime: 0,
+ gcTime: 0
+ });
+};
diff --git a/frontend/src/hooks/api/sshHostGroup/types.ts b/frontend/src/hooks/api/sshHostGroup/types.ts
new file mode 100644
index 000000000..6e193c8b6
--- /dev/null
+++ b/frontend/src/hooks/api/sshHostGroup/types.ts
@@ -0,0 +1,34 @@
+import { TLoginMapping, TSshHost } from "../sshHost/types";
+
+export type TSshHostGroup = {
+ id: string;
+ projectId: string;
+ name: string;
+ loginMappings: Omit[];
+};
+
+export type TCreateSshHostGroupDTO = {
+ projectId: string;
+ name: string;
+ loginMappings: Omit[];
+};
+
+export type TUpdateSshHostGroupDTO = {
+ sshHostGroupId: string;
+ name?: string;
+ loginMappings?: Omit[];
+};
+
+export type TDeleteSshHostGroupDTO = {
+ sshHostGroupId: string;
+};
+
+export type TListSshHostGroupHostsResponse = {
+ hosts: (TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean })[];
+ totalCount: number;
+};
+
+export enum EHostGroupMembershipFilter {
+ GROUP_MEMBERS = "group-members",
+ NON_GROUP_MEMBERS = "non-group-members"
+}
diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts
index ab277ddc8..ec7b6a2dd 100644
--- a/frontend/src/hooks/api/subscriptions/types.ts
+++ b/frontend/src/hooks/api/subscriptions/types.ts
@@ -24,6 +24,7 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
+ sshHostGroups: boolean;
secretAccessInsights: boolean;
hsm: boolean;
oidcSSO: boolean;
diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx
index a7b402d3c..b841f4bff 100644
--- a/frontend/src/hooks/api/workspace/index.tsx
+++ b/frontend/src/hooks/api/workspace/index.tsx
@@ -38,6 +38,7 @@ export {
useListWorkspaceSshCas,
useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates,
+ useListWorkspaceSshHostGroups,
useListWorkspaceSshHosts,
useNameWorkspaceSecrets,
useSearchProjects,
diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx
index c869bd66f..441b9aefa 100644
--- a/frontend/src/hooks/api/workspace/queries.tsx
+++ b/frontend/src/hooks/api/workspace/queries.tsx
@@ -18,6 +18,7 @@ import { EncryptedSecret } from "../secrets/types";
import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types";
import { TSshCertificateTemplate } from "../sshCertificateTemplates/types";
import { TSshHost } from "../sshHost/types";
+import { TSshHostGroup } from "../sshHostGroup/types";
import { userKeys } from "../users/query-keys";
import { TWorkspaceUser } from "../users/types";
import {
@@ -873,6 +874,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => {
});
};
+export const useListWorkspaceSshHostGroups = (projectId: string) => {
+ return useQuery({
+ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId),
+ queryFn: async () => {
+ const {
+ data: { groups }
+ } = await apiRequest.get<{ groups: (TSshHostGroup & { hostCount: number })[] }>(
+ `/api/v2/workspace/${projectId}/ssh-host-groups`
+ );
+ return groups;
+ },
+ enabled: Boolean(projectId)
+ });
+};
+
export const useListWorkspaceSshCertificateTemplates = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId),
diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx
index fb2d30ce8..91fe95a04 100644
--- a/frontend/src/hooks/api/workspace/query-keys.tsx
+++ b/frontend/src/hooks/api/workspace/query-keys.tsx
@@ -63,6 +63,8 @@ export const workspaceKeys = {
allWorkspaceSshCertificates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificates"] as const,
getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const,
+ getWorkspaceSshHostGroups: (projectId: string) =>
+ [{ projectId }, "workspace-ssh-host-groups"] as const,
specificWorkspaceSshCertificates: ({
offset,
limit,
diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx
index 1f1f0760c..790adff50 100644
--- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx
+++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx
@@ -238,7 +238,7 @@ export const OverviewPage = () => {
Default organization
- Select the default organization you want to set for SAML/LDAP/OIDC based
+ Select the default organization you want to set for SAML/LDAP/OIDC/Github
logins. When selected, user logins will be automatically scoped to the
selected organization.
diff --git a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx
index 40075ae54..6cb4da82b 100644
--- a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx
+++ b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx
@@ -13,6 +13,7 @@ export const SignupSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: ROUTE_PATHS.Auth.SignUpSsoPage.id });
const token = search.token as string;
+ const defaultOrgAllowed = search.defaultOrgAllowed as boolean | undefined;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
@@ -57,6 +58,7 @@ export const SignupSsoPage = () => {
password={password}
setPassword={setPassword}
providerAuthToken={token}
+ forceDefaultOrg={defaultOrgAllowed}
/>
);
default:
diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx
index a6ab0623b..2f2ade8e7 100644
--- a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx
+++ b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx
@@ -30,6 +30,7 @@ type Props = {
name: string;
providerOrganizationName: string;
providerAuthToken?: string;
+ forceDefaultOrg?: boolean;
};
/**
@@ -51,7 +52,8 @@ export const UserInfoSSOStep = ({
providerOrganizationName,
password,
setPassword,
- providerAuthToken
+ providerAuthToken,
+ forceDefaultOrg
}: Props) => {
const [nameError, setNameError] = useState(false);
const [organizationName, setOrganizationName] = useState("");
@@ -84,7 +86,7 @@ export const UserInfoSSOStep = ({
} else {
setNameError(false);
}
- if (!organizationName) {
+ if (!organizationName && !forceDefaultOrg) {
setOrganizationNameError(true);
errorCheck = true;
} else {
@@ -160,7 +162,8 @@ export const UserInfoSSOStep = ({
salt: result.salt,
verifier: result.verifier,
organizationName,
- attributionSource
+ attributionSource,
+ useDefaultOrg: forceDefaultOrg
});
// unset signup JWT token and set JWT token
@@ -267,7 +270,7 @@ export const UserInfoSSOStep = ({
)}
- {providerOrganizationName === undefined && (
+ {!forceDefaultOrg && providerOrganizationName === undefined && (
Organization Name
@@ -279,7 +282,7 @@ export const UserInfoSSOStep = ({
isRequired
className="h-12"
maxLength={64}
- disabled
+ isDisabled={forceDefaultOrg}
/>
{organizationNameError && (
diff --git a/frontend/src/pages/auth/SignUpSsoPage/route.tsx b/frontend/src/pages/auth/SignUpSsoPage/route.tsx
index a255efaf6..986735ad0 100644
--- a/frontend/src/pages/auth/SignUpSsoPage/route.tsx
+++ b/frontend/src/pages/auth/SignUpSsoPage/route.tsx
@@ -5,7 +5,8 @@ import { z } from "zod";
import { SignupSsoPage } from "./SignUpSsoPage";
const SignupSSOPageQueryParamsSchema = z.object({
- token: z.string()
+ token: z.string(),
+ defaultOrgAllowed: z.boolean().optional()
});
export const Route = createFileRoute("/_restrict-login-signup/signup/sso")({
diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
index de2295940..3a1bdb1d2 100644
--- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
+++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx
@@ -234,6 +234,7 @@ export const projectRoleFormSchema = z.object({
})
.array()
.default([]),
+ [ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
@@ -380,7 +381,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.Kms,
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities,
- ProjectPermissionSub.SshCertificates
+ ProjectPermissionSub.SshCertificates,
+ ProjectPermissionSub.SshHostGroups
].includes(subject)
) {
// from above statement we are sure it won't be undefined
@@ -1064,6 +1066,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert }
]
},
+ [ProjectPermissionSub.SshHostGroups]: {
+ title: "SSH Host Groups",
+ actions: [
+ { label: "Read", value: "read" },
+ { label: "Create", value: "create" },
+ { label: "Modify", value: "edit" },
+ { label: "Remove", value: "delete" }
+ ]
+ },
[ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections",
actions: [
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx
new file mode 100644
index 000000000..c5f13d792
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx
@@ -0,0 +1,157 @@
+import { Helmet } from "react-helmet";
+import { useTranslation } from "react-i18next";
+import { useNavigate, useParams } from "@tanstack/react-router";
+import { twMerge } from "tailwind-merge";
+
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Button,
+ DeleteActionModal,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ PageHeader,
+ Tooltip
+} from "@app/components/v2";
+import { ROUTE_PATHS } from "@app/const/routes";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { useDeleteSshHostGroup, useGetSshHostGroupById } from "@app/hooks/api";
+import { ProjectType } from "@app/hooks/api/workspace/types";
+import { usePopUp } from "@app/hooks/usePopUp";
+
+import { SshHostGroupModal } from "../SshHostsPage/components/SshHostGroupModal";
+import { SshHostGroupDetailsSection, SshHostGroupHostsSection } from "./components";
+
+const Page = () => {
+ const { currentWorkspace } = useWorkspace();
+ const navigate = useNavigate();
+ const projectId = currentWorkspace?.id || "";
+ const sshHostGroupId = useParams({
+ from: ROUTE_PATHS.Ssh.SshHostGroupDetailsByIDPage.id,
+ select: (el) => el.sshHostGroupId
+ });
+ const { data } = useGetSshHostGroupById(sshHostGroupId);
+
+ const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
+
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "sshHostGroup",
+ "deleteSshHostGroup"
+ ] as const);
+
+ const onRemoveSshGroupSubmit = async (groupIdToDelete: string) => {
+ try {
+ if (!projectId) return;
+
+ await deleteSshHostGroup({ sshHostGroupId: groupIdToDelete });
+
+ createNotification({
+ text: "Successfully deleted SSH group",
+ type: "success"
+ });
+
+ handlePopUpClose("deleteSshHostGroup");
+ navigate({
+ to: `/${ProjectType.SSH}/$projectId/overview` as const,
+ params: {
+ projectId
+ }
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to delete SSH group",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+ {data && (
+
+
+
+
+
+
+ More
+
+
+
+
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("deleteSshHostGroup", {
+ groupId: data.id,
+ name: data.name
+ })
+ }
+ disabled={!isAllowed}
+ >
+ Delete SSH Group
+
+ )}
+
+
+
+
+
+
+ )}
+
+
handlePopUpToggle("deleteSshHostGroup", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId)
+ }
+ />
+
+ );
+};
+
+export const SshHostGroupDetailsByIDPage = () => {
+ const { t } = useTranslation();
+ return (
+ <>
+
+ {t("common.head-title", { title: "SSH Group" })}
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx
new file mode 100644
index 000000000..2374492a5
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx
@@ -0,0 +1,127 @@
+import { faServer } from "@fortawesome/free-solid-svg-icons";
+
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Button,
+ EmptyState,
+ Modal,
+ ModalContent,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { useAddHostToSshHostGroup, useListSshHostGroupHosts } from "@app/hooks/api";
+import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ popUp: UsePopUpState<["addHostGroupMembers"]>;
+ handlePopUpToggle: (
+ popUpName: keyof UsePopUpState<["addHostGroupMembers"]>,
+ state?: boolean
+ ) => void;
+};
+
+export const AddHostGroupMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const popUpData = popUp?.addHostGroupMembers?.data as {
+ sshHostGroupId: string;
+ };
+
+ const { data, isPending } = useListSshHostGroupHosts({
+ sshHostGroupId: popUpData?.sshHostGroupId,
+ filter: EHostGroupMembershipFilter.NON_GROUP_MEMBERS
+ });
+ const { mutateAsync: addHostToSshHostGroup, isPending: isAddingHostToSshHostGroup } =
+ useAddHostToSshHostGroup();
+
+ const handleAddHost = async (sshHostId: string) => {
+ try {
+ if (!popUpData?.sshHostGroupId) {
+ createNotification({
+ text: "Some data is missing, please refresh the page and try again",
+ type: "error"
+ });
+ return;
+ }
+
+ await addHostToSshHostGroup({
+ sshHostGroupId: popUpData.sshHostGroupId,
+ sshHostId
+ });
+
+ createNotification({
+ text: "Successfully added host to the group",
+ type: "success"
+ });
+ } catch {
+ createNotification({
+ text: "Failed to add host to the group",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
{
+ handlePopUpToggle("addHostGroupMembers", isOpen);
+ }}
+ >
+
+
+
+
+
+ Alias
+ Hostname
+
+
+
+
+ {isPending && }
+ {!isPending &&
+ data?.hosts?.map((host) => {
+ return (
+
+ {host.alias ?? "-"}
+ {host.hostname}
+
+
+ {(isAllowed) => (
+ handleAddHost(host.id)}
+ >
+ Add
+
+ )}
+
+
+
+ );
+ })}
+
+
+ {!isPending && !data?.hosts?.length && (
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx
new file mode 100644
index 000000000..80f1f8d46
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx
@@ -0,0 +1,84 @@
+import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { IconButton, Tooltip } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { useTimedReset } from "@app/hooks";
+import { useGetSshHostGroupById } from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ sshHostGroupId: string;
+ handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, data?: object) => void;
+};
+
+export const SshHostGroupDetailsSection = ({ sshHostGroupId, handlePopUpOpen }: Props) => {
+ const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset
({
+ initialState: "Copy ID to clipboard"
+ });
+
+ const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId);
+
+ return sshHostGroup ? (
+
+
+
SSH Host Group Details
+
+ {(isAllowed) => {
+ return (
+
+ {
+ e.stopPropagation();
+ handlePopUpOpen("sshHostGroup", {
+ sshHostGroupId: sshHostGroup.id
+ });
+ }}
+ >
+
+
+
+ );
+ }}
+
+
+
+
+
SSH Host Group ID
+
+
{sshHostGroup.id}
+
+
+ {
+ navigator.clipboard.writeText(sshHostGroup.id);
+ setCopyTextId("Copied");
+ }}
+ >
+
+
+
+
+
+
+
+
Name
+
{sshHostGroup.name}
+
+
+
+ ) : (
+
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx
new file mode 100644
index 000000000..88a7eebae
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx
@@ -0,0 +1,113 @@
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { DeleteActionModal, IconButton } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
+import { usePopUp } from "@app/hooks";
+import { useRemoveHostFromSshHostGroup } from "@app/hooks/api";
+
+import { AddHostGroupMemberModal } from "./AddHostGroupMemberModal";
+import { SshHostGroupHostsTable } from "./SshHostGroupHostsTable";
+
+type Props = {
+ sshHostGroupId: string;
+};
+
+export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => {
+ const { subscription } = useSubscription();
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "removeHostFromSshHostGroup",
+ "addHostGroupMembers",
+ "upgradePlan"
+ ] as const);
+
+ const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup();
+
+ const handleAddSshHostModal = () => {
+ if (!subscription?.sshHostGroups) {
+ handlePopUpOpen("upgradePlan", {
+ description:
+ "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license."
+ });
+ } else {
+ handlePopUpOpen("addHostGroupMembers", {
+ sshHostGroupId
+ });
+ }
+ };
+
+ const onRemoveSshHostSubmit = async (sshHostId: string) => {
+ try {
+ await removeHostFromGroup({
+ sshHostId,
+ sshHostGroupId
+ });
+
+ await createNotification({
+ text: "Successfully removed host from SSH group",
+ type: "success"
+ });
+
+ handlePopUpClose("removeHostFromSshHostGroup");
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to remove host from SSH group",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+
SSH Hosts
+
+ {(isAllowed) => (
+ handleAddSshHostModal()}
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
+
+
+
+
+
+
handlePopUpToggle("removeHostFromSshHostGroup", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onRemoveSshHostSubmit(
+ (popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId
+ )
+ }
+ />
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text={(popUp.upgradePlan?.data as { description: string })?.description}
+ />
+
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx
new file mode 100644
index 000000000..7866d2125
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx
@@ -0,0 +1,97 @@
+import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ EmptyState,
+ IconButton,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { useListSshHostGroupHosts } from "@app/hooks/api";
+import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ sshHostGroupId: string;
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<["removeHostFromSshHostGroup"]>,
+ data?: object
+ ) => void;
+};
+
+export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Props) => {
+ const { data, isPending } = useListSshHostGroupHosts({
+ sshHostGroupId,
+ filter: EHostGroupMembershipFilter.GROUP_MEMBERS
+ });
+
+ return (
+
+
+
+
+
+ Alias
+ Hostname
+ Added On
+
+
+
+
+ {isPending && }
+ {!isPending &&
+ data?.hosts.map((host) => {
+ return (
+
+ {host.alias ?? "-"}
+ {host.hostname}
+ {new Date(host.joinedGroupAt).toLocaleDateString()}
+
+
+ {(isAllowed) => (
+
+
+ handlePopUpOpen("removeHostFromSshHostGroup", {
+ sshHostId: host.id,
+ alias: host.alias,
+ hostname: host.hostname
+ })
+ }
+ variant="plain"
+ colorSchema="danger"
+ >
+
+
+
+ )}
+
+
+
+ );
+ })}
+
+
+ {!isPending && !data?.hosts?.length && (
+
+ )}
+
+
+ );
+};
+
+export default SshHostGroupHostsTable;
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx
new file mode 100644
index 000000000..ff25edf77
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx
@@ -0,0 +1,3 @@
+export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection";
+export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection";
+export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable";
diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx
new file mode 100644
index 000000000..2a7c1cde6
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { SshHostGroupDetailsByIDPage } from "./SshHostGroupDetailsByIDPage";
+
+export const Route = createFileRoute(
+ "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId"
+)({
+ component: SshHostGroupDetailsByIDPage
+});
diff --git a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx
index aca76bb73..c6efa9670 100644
--- a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx
+++ b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
-import { SshHostsSection } from "./components";
+import { SshHostGroupsSection, SshHostsSection } from "./components";
export const SshHostsPage = () => {
const { t } = useTranslation();
@@ -19,6 +19,7 @@ export const SshHostsPage = () => {
title="Hosts"
description="Manage your SSH hosts, configure access policies, and define login behavior for secure connections."
/>
+
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx
new file mode 100644
index 000000000..de5d82311
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx
@@ -0,0 +1,396 @@
+import { useEffect, useState } from "react";
+import { Controller, useFieldArray, useForm } from "react-hook-form";
+import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem
+} from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+import {
+ useCreateSshHostGroup,
+ useGetSshHostGroupById,
+ useGetWorkspaceUsers,
+ useListWorkspaceSshHostGroups,
+ useUpdateSshHostGroup
+} from "@app/hooks/api";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ popUp: UsePopUpState<["sshHostGroup"]>;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, state?: boolean) => void;
+};
+
+const schema = z
+ .object({
+ name: z.string().trim().min(1).max(64),
+ loginMappings: z
+ .object({
+ loginUser: z.string().trim().min(1),
+ allowedPrincipals: z.array(z.string().trim()).default([])
+ })
+ .array()
+ .default([])
+ })
+ .required();
+
+export type FormData = z.infer;
+
+export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
+ const { currentWorkspace } = useWorkspace();
+ const projectId = currentWorkspace?.id || "";
+ const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id);
+ const { data: members = [] } = useGetWorkspaceUsers(projectId);
+ const [expandedMappings, setExpandedMappings] = useState>({});
+
+ const { data: sshHostGroup } = useGetSshHostGroupById(
+ (popUp?.sshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId || ""
+ );
+
+ const { mutateAsync: createMutateAsync } = useCreateSshHostGroup();
+ const { mutateAsync: updateMutateAsync } = useUpdateSshHostGroup();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ getValues,
+ setValue,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: "",
+ loginMappings: []
+ }
+ });
+
+ const loginMappingsFormFields = useFieldArray({
+ control,
+ name: "loginMappings"
+ });
+
+ useEffect(() => {
+ if (sshHostGroup) {
+ reset({
+ name: sshHostGroup.name,
+ loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
+ loginUser,
+ allowedPrincipals: allowedPrincipals.usernames
+ }))
+ });
+
+ setExpandedMappings(
+ Object.fromEntries(sshHostGroup.loginMappings.map((_, index) => [index, false]))
+ );
+ } else {
+ reset({
+ name: "",
+ loginMappings: []
+ });
+ }
+ }, [sshHostGroup]);
+
+ const onFormSubmit = async ({ name, loginMappings }: FormData) => {
+ try {
+ if (!projectId) return;
+
+ // check if there is already a different host group with the same name
+ const existingNames =
+ sshHostGroups?.filter((h) => h.id !== sshHostGroup?.id).map((h) => h.name) || [];
+
+ if (existingNames.includes(name.trim())) {
+ createNotification({
+ text: "A host group with this name already exists.",
+ type: "error"
+ });
+ return;
+ }
+
+ if (sshHostGroup) {
+ await updateMutateAsync({
+ sshHostGroupId: sshHostGroup.id,
+ name,
+ loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
+ loginUser,
+ allowedPrincipals: {
+ usernames: allowedPrincipals
+ }
+ }))
+ });
+ } else {
+ await createMutateAsync({
+ projectId,
+ name,
+ loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
+ loginUser,
+ allowedPrincipals: {
+ usernames: allowedPrincipals
+ }
+ }))
+ });
+ }
+
+ reset();
+ handlePopUpToggle("sshHostGroup", false);
+
+ createNotification({
+ text: `Successfully ${sshHostGroup ? "updated" : "created"} SSH host group`,
+ type: "success"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: `Failed to ${sshHostGroup ? "update" : "create"} SSH host group`,
+ type: "error"
+ });
+ }
+ };
+
+ const toggleMapping = (index: number) => {
+ setExpandedMappings((prev) => ({
+ ...prev,
+ [index]: !prev[index]
+ }));
+ };
+
+ return (
+ {
+ reset();
+ handlePopUpToggle("sshHostGroup", isOpen);
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx
new file mode 100644
index 000000000..70036d88a
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx
@@ -0,0 +1,96 @@
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { Button, DeleteActionModal } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
+import { useDeleteSshHostGroup } from "@app/hooks/api";
+import { usePopUp } from "@app/hooks/usePopUp";
+
+import { SshHostGroupModal } from "./SshHostGroupModal";
+import { SshHostGroupsTable } from "./SshHostGroupsTable";
+
+export const SshHostGroupsSection = () => {
+ const { subscription } = useSubscription();
+ const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
+
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "sshHostGroup",
+ "deleteSshHostGroup",
+ "upgradePlan"
+ ] as const);
+
+ const handleAddSshHostGroupModal = () => {
+ if (!subscription?.sshHostGroups) {
+ handlePopUpOpen("upgradePlan", {
+ description:
+ "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license."
+ });
+ } else {
+ handlePopUpOpen("sshHostGroup");
+ }
+ };
+
+ const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => {
+ try {
+ const hostGroup = await deleteSshHostGroup({ sshHostGroupId });
+
+ createNotification({
+ text: `Successfully deleted SSH host group: ${hostGroup.name}`,
+ type: "success"
+ });
+
+ handlePopUpClose("deleteSshHostGroup");
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to delete SSH host group",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+
Host Groups
+
+ {(isAllowed) => (
+ }
+ onClick={() => handleAddSshHostGroupModal()}
+ isDisabled={!isAllowed}
+ >
+ Add Group
+
+ )}
+
+
+
+
+
handlePopUpToggle("deleteSshHostGroup", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onRemoveSshHostGroupSubmit(
+ (popUp?.deleteSshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId
+ )
+ }
+ />
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text={(popUp.upgradePlan?.data as { description: string })?.description}
+ />
+
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx
new file mode 100644
index 000000000..194d9e90e
--- /dev/null
+++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx
@@ -0,0 +1,184 @@
+import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useNavigate } from "@tanstack/react-router";
+import { twMerge } from "tailwind-merge";
+
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { useListWorkspaceSshHostGroups } from "@app/hooks/api";
+import { ProjectType } from "@app/hooks/api/workspace/types";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<["deleteSshHostGroup", "sshHostGroup"]>,
+ data?: object
+ ) => void;
+};
+
+export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
+ const navigate = useNavigate();
+ const { currentWorkspace } = useWorkspace();
+ const { data, isPending } = useListWorkspaceSshHostGroups(currentWorkspace?.id || "");
+ return (
+
+
+
+
+
+ Name
+ # Hosts in Group
+ Login User - Authorized Principals Mapping
+
+
+
+
+ {isPending && }
+ {!isPending &&
+ data &&
+ data.length > 0 &&
+ data.map((group) => {
+ return (
+
+ navigate({
+ to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const,
+ params: {
+ projectId: currentWorkspace.id,
+ sshHostGroupId: group.id
+ }
+ })
+ }
+ >
+ {group.name}
+ {group.hostCount}
+
+ {group.loginMappings.length === 0 ? (
+ None
+ ) : (
+ group.loginMappings.map(({ loginUser, allowedPrincipals }) => (
+
+
{loginUser}
+ {allowedPrincipals.usernames.map((username) => (
+
+ └─ {username}
+
+ ))}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("sshHostGroup", {
+ sshHostGroupId: group.id
+ });
+ }}
+ disabled={!isAllowed}
+ icon={ }
+ >
+ Edit Host Group
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ navigate({
+ to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const,
+ params: {
+ projectId: currentWorkspace.id,
+ sshHostGroupId: group.id
+ }
+ });
+ }}
+ disabled={!isAllowed}
+ icon={ }
+ >
+ Manage Hosts
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("deleteSshHostGroup", {
+ sshHostGroupId: group.id,
+ name: group.name
+ });
+ }}
+ disabled={!isAllowed}
+ icon={ }
+ >
+ Delete Host Group
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+ {!isPending && data?.length === 0 && (
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx
index ede0a35a0..5ac53a16e 100644
--- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx
+++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx
@@ -26,6 +26,7 @@ import {
useListWorkspaceSshHosts,
useUpdateSshHost
} from "@app/hooks/api";
+import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -48,7 +49,8 @@ const schema = z
loginMappings: z
.object({
loginUser: z.string().trim().min(1),
- allowedPrincipals: z.array(z.string().trim()).default([])
+ allowedPrincipals: z.array(z.string().trim()).default([]),
+ source: z.nativeEnum(LoginMappingSource)
})
.array()
.default([])
@@ -99,9 +101,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname: sshHost.hostname,
alias: sshHost.alias ?? "",
userCertTtl: sshHost.userCertTtl,
- loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
+ loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({
loginUser,
- allowedPrincipals: allowedPrincipals.usernames
+ allowedPrincipals: allowedPrincipals.usernames,
+ source
}))
});
@@ -122,6 +125,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
try {
if (!projectId) return;
+ // Filter out login mappings that are from host groups
+ const hostLoginMappings = loginMappings.filter(
+ (mapping) => mapping.source === LoginMappingSource.HOST
+ );
+
// check if there is already a different host with the same hostname
const existingHostnames =
sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || [];
@@ -157,7 +165,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname,
alias: trimmedAlias,
userCertTtl,
- loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
+ loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
@@ -170,7 +178,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname,
alias: trimmedAlias,
userCertTtl,
- loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
+ loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
@@ -265,7 +273,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="outline_bg"
onClick={() => {
const newIndex = loginMappingsFormFields.fields.length;
- loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
+ loginMappingsFormFields.append({
+ loginUser: "",
+ allowedPrincipals: [""],
+ source: LoginMappingSource.HOST
+ });
setExpandedMappings((prev) => ({
...prev,
[newIndex]: true
@@ -298,6 +310,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
render={({ field }) => (
{field.value || "New Login Mapping"}
+ {loginMappingsFormFields.fields[i].source ===
+ LoginMappingSource.HOST_GROUP && (
+
+ (inherited from host group)
+
+ )}
)}
/>
@@ -306,6 +324,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
ariaLabel="delete login mapping"
variant="plain"
onClick={() => loginMappingsFormFields.remove(i)}
+ isDisabled={
+ loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST_GROUP
+ }
>
@@ -327,11 +348,24 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
{
+ if (
+ loginMappingsFormFields.fields[i].source ===
+ LoginMappingSource.HOST_GROUP
+ )
+ return;
+
const newValue = e.target.value;
const loginMappings = getValues("loginMappings");
const isDuplicate = loginMappings.some(
- (mapping, index) => index !== i && mapping.loginUser === newValue
+ (mapping, index) =>
+ index !== i &&
+ mapping.loginUser === newValue &&
+ mapping.source === LoginMappingSource.HOST
);
if (isDuplicate) {
@@ -355,17 +389,20 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
label="Allowed Principals"
className="text-xs text-mineshaft-400"
/>
- }
- size="xs"
- variant="outline_bg"
- onClick={() => {
- const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
- setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
- }}
- >
- Add Principal
-
+ {loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && (
+ }
+ size="xs"
+ variant="outline_bg"
+ onClick={() => {
+ const current =
+ getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
+ setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
+ }}
+ >
+ Add Principal
+
+ )}
{
{
+ if (
+ loginMappingsFormFields.fields[i].source ===
+ LoginMappingSource.HOST_GROUP
+ )
+ return;
+
if (value.includes(newValue)) {
createNotification({
text: "This principal is already added",
@@ -395,6 +438,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
}}
placeholder="Select a member"
className="w-full"
+ isDisabled={
+ loginMappingsFormFields.fields[i].source ===
+ LoginMappingSource.HOST_GROUP
+ }
>
{members.map((member) => (
{
variant="plain"
className="h-9"
onClick={() => {
+ if (
+ loginMappingsFormFields.fields[i].source ===
+ LoginMappingSource.HOST_GROUP
+ )
+ return;
+
const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex
);
onChange(newPrincipals);
}}
+ isDisabled={
+ loginMappingsFormFields.fields[i].source ===
+ LoginMappingSource.HOST_GROUP
+ }
>
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx
index 6efe40b36..491307d21 100644
--- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx
+++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx
@@ -29,6 +29,7 @@ import {
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api";
+import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -63,7 +64,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
return (
-
+
Alias
@@ -90,16 +91,73 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
{host.loginMappings.length === 0 ? (
None
) : (
- host.loginMappings.map(({ loginUser, allowedPrincipals }) => (
-
-
{loginUser}
- {allowedPrincipals.usernames.map((username) => (
-
- └─ {username}
+ (() => {
+ const hostMappings = host.loginMappings.filter(
+ (m) => m.source !== LoginMappingSource.HOST_GROUP
+ );
+ const groupMappings = host.loginMappings.filter(
+ (m) => m.source === LoginMappingSource.HOST_GROUP
+ );
+
+ const hostLoginUserToPrincipals = hostMappings.reduce(
+ (acc, { loginUser, allowedPrincipals }) => {
+ acc[loginUser] = new Set(allowedPrincipals.usernames);
+ return acc;
+ },
+ {} as Record
>
+ );
+
+ const entriesFromHost = hostMappings.map(
+ ({ loginUser, allowedPrincipals }) => ({
+ loginUser,
+ source: LoginMappingSource.HOST,
+ usernames: allowedPrincipals.usernames
+ })
+ );
+
+ const entriesFromGroup = groupMappings
+ .map(({ loginUser, allowedPrincipals }) => {
+ const existing = hostLoginUserToPrincipals[loginUser] || new Set();
+ const filteredUsernames = allowedPrincipals.usernames.filter(
+ (u) => !existing.has(u)
+ );
+ return filteredUsernames.length > 0
+ ? {
+ loginUser,
+ source: LoginMappingSource.HOST_GROUP,
+ usernames: filteredUsernames
+ }
+ : null;
+ })
+ .filter(Boolean) as {
+ loginUser: string;
+ source: LoginMappingSource;
+ usernames: string[];
+ }[];
+
+ return [...entriesFromHost, ...entriesFromGroup]
+ .sort((a, b) => a.loginUser.localeCompare(b.loginUser))
+ .map(({ loginUser, usernames, source }) => (
+
+
+ {loginUser}
+ {source === LoginMappingSource.HOST_GROUP && (
+
+ (inherited from host group)
+
+ )}
+
+ {usernames.map((username) => (
+
+ └─ {username}
+
+ ))}
- ))}
-
- ))
+ ));
+ })()
)}
@@ -139,7 +197,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
disabled={!isAllowed}
icon={ }
>
- Edit SSH host
+ Edit Host
)}
@@ -161,7 +219,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
disabled={!isAllowed}
icon={ }
>
- Delete SSH host
+ Delete Host
)}
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 2047dc070..15d67ab46 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -108,6 +108,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 sshSshHostGroupDetailsByIDPageRouteImport } from './pages/ssh/SshHostGroupDetailsByIDPage/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'
@@ -1022,6 +1023,13 @@ const projectIdentityDetailsByIDPageRouteCertManagerRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
+const sshSshHostGroupDetailsByIDPageRouteRoute =
+ sshSshHostGroupDetailsByIDPageRouteImport.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',
@@ -2414,6 +2422,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 sshSshHostGroupDetailsByIDPageRouteImport
+ 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'
@@ -3599,6 +3614,7 @@ interface sshLayoutRouteChildren {
sshSettingsPageRouteRoute: typeof sshSettingsPageRouteRoute
projectAccessControlPageRouteSshRoute: typeof projectAccessControlPageRouteSshRoute
sshSshCaByIDPageRouteRoute: typeof sshSshCaByIDPageRouteRoute
+ sshSshHostGroupDetailsByIDPageRouteRoute: typeof sshSshHostGroupDetailsByIDPageRouteRoute
projectIdentityDetailsByIDPageRouteSshRoute: typeof projectIdentityDetailsByIDPageRouteSshRoute
projectMemberDetailsByIDPageRouteSshRoute: typeof projectMemberDetailsByIDPageRouteSshRoute
projectRoleDetailsBySlugPageRouteSshRoute: typeof projectRoleDetailsBySlugPageRouteSshRoute
@@ -3611,6 +3627,8 @@ const sshLayoutRouteChildren: sshLayoutRouteChildren = {
sshSettingsPageRouteRoute: sshSettingsPageRouteRoute,
projectAccessControlPageRouteSshRoute: projectAccessControlPageRouteSshRoute,
sshSshCaByIDPageRouteRoute: sshSshCaByIDPageRouteRoute,
+ sshSshHostGroupDetailsByIDPageRouteRoute:
+ sshSshHostGroupDetailsByIDPageRouteRoute,
projectIdentityDetailsByIDPageRouteSshRoute:
projectIdentityDetailsByIDPageRouteSshRoute,
projectMemberDetailsByIDPageRouteSshRoute:
@@ -3921,6 +3939,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 sshSshHostGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4098,6 +4117,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 sshSshHostGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4294,6 +4314,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 sshSshHostGroupDetailsByIDPageRouteRoute
'/_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
@@ -4482,6 +4503,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'
@@ -4658,6 +4680,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'
@@ -4852,6 +4875,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'
@@ -5392,6 +5416,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"
@@ -5629,6 +5654,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/SshHostGroupDetailsByIDPage/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 4bfb82fad..3d9889ab3 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -315,6 +315,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/SshHostGroupDetailsByIDPage/route.tsx"),
route("/settings", "ssh/SettingsPage/route.tsx"),
route("/access-management", "project/AccessControlPage/route-ssh.tsx"),
route("/roles/$roleSlug", "project/RoleDetailsBySlugPage/route-ssh.tsx"),