Merge branch 'main' into ENG-2647

This commit is contained in:
x
2025-05-02 19:03:08 -04:00
108 changed files with 3763 additions and 258 deletions

View File

@@ -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
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

View File

@@ -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;

View File

@@ -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<TUsers, TUsersInsert, TUsersUpdate>;
[TableName.Groups]: KnexOriginal.CompositeTableType<TGroups, TGroupsInsert, TGroupsUpdate>;
[TableName.SshHostGroup]: KnexOriginal.CompositeTableType<
TSshHostGroups,
TSshHostGroupsInsert,
TSshHostGroupsUpdate
>;
[TableName.SshHostGroupMembership]: KnexOriginal.CompositeTableType<
TSshHostGroupMemberships,
TSshHostGroupMembershipsInsert,
TSshHostGroupMembershipsUpdate
>;
[TableName.SshHost]: KnexOriginal.CompositeTableType<TSshHosts, TSshHostsInsert, TSshHostsUpdate>;
[TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType<
TSshCertificateAuthorities,

View File

@@ -0,0 +1,55 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
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);
}

View File

@@ -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";

View File

@@ -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",

View File

@@ -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<typeof SshHostGroupMembershipsSchema>;
export type TSshHostGroupMembershipsInsert = Omit<z.input<typeof SshHostGroupMembershipsSchema>, TImmutableDBKeys>;
export type TSshHostGroupMembershipsUpdate = Partial<
Omit<z.input<typeof SshHostGroupMembershipsSchema>, TImmutableDBKeys>
>;

View File

@@ -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<typeof SshHostGroupsSchema>;
export type TSshHostGroupsInsert = Omit<z.input<typeof SshHostGroupsSchema>, TImmutableDBKeys>;
export type TSshHostGroupsUpdate = Partial<Omit<z.input<typeof SshHostGroupsSchema>, TImmutableDBKeys>>;

View File

@@ -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<typeof SshHostLoginUsersSchema>;

View File

@@ -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" }
);

View File

@@ -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;
}
});
};

View File

@@ -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)

View File

@@ -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

View File

@@ -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" });
}
};

View File

@@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => {
has_used_trial: true,
secretApproval: true,
secretRotation: true,
caCrl: false
caCrl: false,
sshHostGroups: false
};
};

View File

@@ -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" },

View File

@@ -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) => {

View File

@@ -71,6 +71,7 @@ export type TFeatureSet = {
projectTemplates: false;
kmip: false;
gateway: false;
sshHostGroups: false;
};
export type TOrgPlansTableDTO = {

View File

@@ -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<ProjectPermissionSub.SshHosts> & 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(
[

View File

@@ -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<typeof sshHostGroupDALFactory>;
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<Record<string, number>>((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
};
};

View File

@@ -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<typeof sshHostGroupMembershipDALFactory>;
export const sshHostGroupMembershipDALFactory = (db: TDbClient) => {
const sshHostGroupMembershipOrm = ormify(db, TableName.SshHostGroupMembership);
return {
...sshHostGroupMembershipOrm
};
};

View File

@@ -0,0 +1,7 @@
import { SshHostGroupsSchema } from "@app/db/schemas";
export const sanitizedSshHostGroup = SshHostGroupsSchema.pick({
id: true,
projectId: true,
name: true
});

View File

@@ -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<TProjectDALFactory, "findById" | "find">;
sshHostDAL: Pick<TSshHostDALFactory, "findSshHostByIdWithLoginMappings">;
sshHostGroupDAL: Pick<
TSshHostGroupDALFactory,
| "create"
| "updateById"
| "findById"
| "deleteById"
| "transaction"
| "findSshHostGroupByIdWithLoginMappings"
| "findAllSshHostsInGroup"
| "findOne"
| "find"
>;
sshHostGroupMembershipDAL: Pick<TSshHostGroupMembershipDALFactory, "create" | "deleteById" | "findOne">;
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>;
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
};
};

View File

@@ -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<TProjectPermission, "projectId">;
export type TGetSshHostGroupDTO = {
sshHostGroupId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string;
} & Omit<TProjectPermission, "projectId">;
export type TListSshHostGroupHostsDTO = {
sshHostGroupId: string;
filter?: EHostGroupMembershipFilter;
} & Omit<TProjectPermission, "projectId">;
export type TAddHostToSshHostGroupDTO = {
sshHostGroupId: string;
hostId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRemoveHostFromSshHostGroupDTO = {
sshHostGroupId: string;
hostId: string;
} & Omit<TProjectPermission, "projectId">;
export enum EHostGroupMembershipFilter {
GROUP_MEMBERS = "group-members",
NON_GROUP_MEMBERS = "non-group-members"
}

View File

@@ -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<typeof sshHostDALFactory>;
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
};

View File

@@ -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);
};

View File

@@ -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
});
}
}

View File

@@ -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<TProjectPermission, "projectId">;
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<TProjectPermission, "projectId">;
export type TGetSshHostDTO = {
@@ -48,3 +57,19 @@ export type TIssueSshHostHostCertDTO = {
sshHostId: string;
publicKey: string;
} & Omit<TProjectPermission, "projectId">;
type BaseCreateSshLoginMappingsDTO = {
loginMappings: TLoginMapping[];
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getUserProjectPermission">;
projectId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
tx?: Knex;
};
export type TCreateSshLoginMappingsDTO =
| (BaseCreateSshLoginMappingsDTO & { sshHostId: string; sshHostGroupId?: undefined })
| (BaseCreateSshLoginMappingsDTO & { sshHostGroupId: string; sshHostId?: undefined });

View File

@@ -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."

View File

@@ -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,

View File

@@ -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` : ""
}`
);
}
});

View File

@@ -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 };
}
});
};

View File

@@ -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(),

View File

@@ -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<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
orgMembershipDAL: TOrgMembershipDALFactory;
};
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
@@ -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) {

View File

@@ -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(

View File

@@ -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 = {

View File

@@ -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<TSshCertificateDALFactory, "find" | "countSshCertificatesInProject">;
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find">;
sshHostDAL: Pick<TSshHostDALFactory, "find" | "findSshHostsWithLoginMappings">;
sshHostGroupDAL: Pick<TSshHostGroupDALFactory, "find" | "findSshHostGroupsWithLoginMappings">;
permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
smtpService: Pick<TSmtpService, "sendMail">;
orgDAL: Pick<TOrgDALFactory, "findOne">;
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
projectBotDAL: Pick<TProjectBotDALFactory, "create">;
@@ -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,

View File

@@ -1,4 +1,4 @@
---
title: "Find By Privilege Slug"
title: "Find By Slug"
openapi: "GET /api/v1/additional-privilege/identity/{privilegeSlug}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v2/identity-project-additional-privilege"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v2/identity-project-additional-privilege/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Find By ID"
openapi: "GET /api/v2/identity-project-additional-privilege/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Find By Slug"
openapi: "GET /api/v2/identity-project-additional-privilege/slug/{privilegeSlug}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/identity-project-additional-privilege"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v2/identity-project-additional-privilege/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Add Host"
openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/ssh/host-groups"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List Hosts"
openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups"
---

View File

@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Remove Host"
openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/ssh/hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Issue Host Certificate"
openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert"
---

View File

@@ -0,0 +1,4 @@
---
title: "Issue User Certificate"
openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert"
---

View File

@@ -0,0 +1,4 @@
---
title: "List My Hosts"
openapi: "GET /api/v1/ssh/hosts/"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "Read Host CA Public Key"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key"
---

View File

@@ -0,0 +1,4 @@
---
title: "Read User CA Public Key"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key"
---

View File

@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}"
---

View File

@@ -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.
<Steps>
<Step title="Create a host group">
1.1. Navigate to your Infisical SSH project and select the **Hosts** tab.
1.2. Click **Add Group** in the **Host Groups** section to create a new group.
Enter a name (e.g., `production-servers` or `tokyo-region`) and login mapping(s) for the host group.
A login mapping for a host group applies to all hosts assigned to the group and dictates what user(s) will be allowed access to the remote hosts
in that group under specific login user(s); in the allowed principals, you should select user(s) part of the Infisical SSH project that will
be allowed to login to the remote host as the login user.
For instance, if you add a mapping to a host group with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to any remote host that is part of the group as `ec2-user` which is a system user that
exists on the remote host(s).
![ssh group add group](/images/platform/ssh/v2/ssh-group-add-group-1.png)
![ssh group add group 2](/images/platform/ssh/v2/ssh-group-add-group-2.png)
1.3. Click **Add** to create the host group.
</Step>
<Step title="Add host(s) to the host group">
After creating the host group, you can assign a host to it from inside the host group page in the **SSH Hosts** section. Generally, this is where you'll manage the hosts in a group.
![ssh group add group - add host](/images/platform/ssh/v2/ssh-group-add-host-1.png)
![ssh group add group - added host](/images/platform/ssh/v2/ssh-group-add-host-2.png)
</Step>
</Steps>

View File

@@ -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."
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

View File

@@ -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": [

View File

@@ -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: {

View File

@@ -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]

View File

@@ -107,6 +107,7 @@ export type CompleteAccountSignupDTO = CompleteAccountDTO & {
providerAuthToken?: string;
attributionSource?: string;
organizationName: string;
useDefaultOrg?: boolean;
};
export type VerifySignupInviteDTO = {

View File

@@ -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";

View File

@@ -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<TLoginMapping, "source">[];
};
export type TUpdateSshHostDTO = {
@@ -33,12 +35,7 @@ export type TUpdateSshHostDTO = {
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
loginMappings?: Omit<TLoginMapping, "source">[];
};
export type TDeleteSshHostDTO = {

View File

@@ -0,0 +1,8 @@
export {
useAddHostToSshHostGroup,
useCreateSshHostGroup,
useDeleteSshHostGroup,
useRemoveHostFromSshHostGroup,
useUpdateSshHostGroup
} from "./mutations";
export { useGetSshHostGroupById, useListSshHostGroupHosts } from "./queries";

View File

@@ -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<TSshHostGroup, object, TCreateSshHostGroupDTO>({
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<TSshHostGroup, object, TUpdateSshHostGroupDTO>({
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<TSshHostGroup, object, TDeleteSshHostGroupDTO>({
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<void, object, { sshHostGroupId: string; sshHostId: string }>({
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<void, object, { sshHostGroupId: string; sshHostId: string }>({
mutationFn: async ({ sshHostGroupId, sshHostId }) => {
await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
},
onSuccess: (_, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
});
}
});
};

View File

@@ -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<TSshHostGroup>(
`/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<TListSshHostGroupHostsResponse>(
`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`,
{
params
}
);
return data;
},
enabled: Boolean(sshHostGroupId),
staleTime: 0,
gcTime: 0
});
};

View File

@@ -0,0 +1,34 @@
import { TLoginMapping, TSshHost } from "../sshHost/types";
export type TSshHostGroup = {
id: string;
projectId: string;
name: string;
loginMappings: Omit<TLoginMapping, "source">[];
};
export type TCreateSshHostGroupDTO = {
projectId: string;
name: string;
loginMappings: Omit<TLoginMapping, "source">[];
};
export type TUpdateSshHostGroupDTO = {
sshHostGroupId: string;
name?: string;
loginMappings?: Omit<TLoginMapping, "source">[];
};
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"
}

View File

@@ -24,6 +24,7 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
sshHostGroups: boolean;
secretAccessInsights: boolean;
hsm: boolean;
oidcSSO: boolean;

View File

@@ -38,6 +38,7 @@ export {
useListWorkspaceSshCas,
useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates,
useListWorkspaceSshHostGroups,
useListWorkspaceSshHosts,
useNameWorkspaceSecrets,
useSearchProjects,

View File

@@ -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),

View File

@@ -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,

View File

@@ -238,7 +238,7 @@ export const OverviewPage = () => {
Default organization
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
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.
</div>

View File

@@ -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:

View File

@@ -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 = ({
</p>
)}
</div>
{providerOrganizationName === undefined && (
{!forceDefaultOrg && providerOrganizationName === undefined && (
<div className="relative z-0 flex w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Organization Name
@@ -279,7 +282,7 @@ export const UserInfoSSOStep = ({
isRequired
className="h-12"
maxLength={64}
disabled
isDisabled={forceDefaultOrg}
/>
{organizationNameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">

View File

@@ -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")({

View File

@@ -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: [

View File

@@ -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 (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title={data.name}>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<Button variant="outline_bg">More</Button>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deleteSshHostGroup", {
groupId: data.id,
name: data.name
})
}
disabled={!isAllowed}
>
Delete SSH Group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<SshHostGroupDetailsSection
sshHostGroupId={sshHostGroupId}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<div className="w-full">
<SshHostGroupHostsSection sshHostGroupId={sshHostGroupId} />
</div>
</div>
</div>
)}
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHostGroup.isOpen}
title={`Are you sure want to remove the SSH group: ${
(popUp?.deleteSshHostGroup?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId)
}
/>
</div>
);
};
export const SshHostGroupDetailsByIDPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "SSH Group" })}</title>
</Helmet>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshHostGroups}
passThrough={false}
renderGuardBanner
>
<Page />
</ProjectPermissionCan>
</>
);
};

View File

@@ -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 (
<Modal
isOpen={popUp?.addHostGroupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addHostGroupMembers", isOpen);
}}
>
<ModalContent title="Add Hosts to Group">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="ssh-hosts" />}
{!isPending &&
data?.hosts?.map((host) => {
return (
<Tr className="items-center" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Button
isLoading={isAddingHostToSshHostGroup}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="button"
onClick={() => handleAddHost(host.id)}
>
Add
</Button>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.hosts?.length && (
<EmptyState title="No hosts available to add to the SSH host group" icon={faServer} />
)}
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -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<string>({
initialState: "Copy ID to clipboard"
});
const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId);
return sshHostGroup ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Host Group Details</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => {
return (
<Tooltip content="Edit SSH Host Group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshHostGroup", {
sshHostGroupId: sshHostGroup.id
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">SSH Host Group ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{sshHostGroup.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(sshHostGroup.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{sshHostGroup.name}</p>
</div>
</div>
</div>
) : (
<div />
);
};

View File

@@ -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 (
<div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Hosts</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<IconButton
ariaLabel="add host"
variant="plain"
className="group relative"
onClick={() => handleAddSshHostModal()}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<SshHostGroupHostsTable sshHostGroupId={sshHostGroupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<AddHostGroupMemberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeHostFromSshHostGroup.isOpen}
title={`Are you sure want to remove ${
(popUp?.removeHostFromSshHostGroup?.data as { hostname: string; alias?: string })
?.alias ||
(popUp?.removeHostFromSshHostGroup?.data as { hostname: string; alias?: string })
?.hostname ||
""
} from this host group?`}
onChange={(isOpen) => handlePopUpToggle("removeHostFromSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostSubmit(
(popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -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 (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th>Added On</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="ssh-host-group-hosts" />}
{!isPending &&
data?.hosts.map((host) => {
return (
<Tr className="h-10" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td>{new Date(host.joinedGroupAt).toLocaleDateString()}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Tooltip content="Remove host from group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="Remove host from group"
onClick={() =>
handlePopUpOpen("removeHostFromSshHostGroup", {
sshHostId: host.id,
alias: host.alias,
hostname: host.hostname
})
}
variant="plain"
colorSchema="danger"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.hosts?.length && (
<EmptyState title="No hosts have been added to this SSH host group" icon={faServer} />
)}
</TableContainer>
</div>
);
};
export default SshHostGroupHostsTable;

View File

@@ -0,0 +1,3 @@
export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection";
export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection";
export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable";

View File

@@ -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
});

View File

@@ -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."
/>
<SshHostGroupsSection />
<SshHostsSection />
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More