Begin work on ssh host groups

This commit is contained in:
Tuan Dang
2025-04-29 13:39:24 -07:00
parent 6a26a11cbb
commit a9a16c9bd1
50 changed files with 2473 additions and 122 deletions

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";
@@ -213,6 +214,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,
@@ -445,6 +451,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,57 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
// TODO: can attach default SSH login mappings to a host group
// TODO: can attach default user SSH CA and host SSH CA (convert existing project level ones to a group)
export async function up(knex: Knex): Promise<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.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

@@ -127,6 +127,8 @@ export * from "./ssh-certificate-authority-secrets";
export * from "./ssh-certificate-bodies";
export * from "./ssh-certificate-templates";
export * from "./ssh-certificates";
export * from "./ssh-host-group-memberships";
export * from "./ssh-host-groups";
export * from "./ssh-host-login-user-mappings";
export * from "./ssh-host-login-users";
export * from "./ssh-hosts";

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

@@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({
defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional(),
secretShareSendToAnyone: z.boolean().default(true).nullable().optional(),
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),

View File

@@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false),
hasDeleteProtection: z.boolean().default(true).nullable().optional()
hasDeleteProtection: z.boolean().default(false).nullable().optional()
});
export type TProjects = z.infer<typeof ProjectsSchema>;

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,351 @@
import { z } from "zod";
import { sanitizedSshHost, loginMappingSchema } from "@app/ee/services/ssh-host/ssh-host-schema";
import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema";
import { SSH_HOST_GROUPS } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:sshHostGroupId",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId)
}),
response: {
200: sanitizedSshHostGroup.extend({
loginMappings: z.array(loginMappingSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const sshHostGroup = await server.services.sshHostGroup.getSshHostGroup({
sshHostGroupId: req.params.sshHostGroupId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.GET_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname
// }
// }
// });
return sshHostGroup;
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create SSH Host Group",
body: z.object({
projectId: z.string().describe(SSH_HOST_GROUPS.CREATE.projectId),
name: slugSchema({ min: 0, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.CREATE.name),
loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOST_GROUPS.CREATE.loginMappings)
}),
response: {
200: sanitizedSshHostGroup.extend({
loginMappings: z.array(loginMappingSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const sshHostGroup = await server.services.sshHostGroup.createSshHostGroup({
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// TODO: audit logs
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.CREATE_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname,
// alias: host.alias ?? null,
// userCertTtl: host.userCertTtl,
// hostCertTtl: host.hostCertTtl,
// loginMappings: host.loginMappings,
// userSshCaId: host.userSshCaId,
// hostSshCaId: host.hostSshCaId
// }
// }
// });
return sshHostGroup;
}
});
server.route({
method: "PATCH",
url: "/:sshHostGroupId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Update SSH Host",
params: z.object({
sshHostGroupId: z.string().trim().describe(SSH_HOST_GROUPS.UPDATE.sshHostGroupId)
}),
body: z.object({
name: slugSchema({ min: 0, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.UPDATE.name).optional(),
loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOST_GROUPS.UPDATE.loginMappings)
}),
response: {
200: sanitizedSshHostGroup.extend({
loginMappings: z.array(loginMappingSchema)
})
}
},
handler: async (req) => {
const sshHostGroup = await server.services.sshHostGroup.updateSshHostGroup({
sshHostGroupId: req.params.sshHostGroupId,
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.UPDATE_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname,
// alias: host.alias,
// userCertTtl: host.userCertTtl,
// hostCertTtl: host.hostCertTtl,
// loginMappings: host.loginMappings,
// userSshCaId: host.userSshCaId,
// hostSshCaId: host.hostSshCaId
// }
// }
// });
return sshHostGroup;
}
});
server.route({
method: "DELETE",
url: "/:sshHostGroupId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE.sshHostGroupId)
}),
response: {
200: sanitizedSshHostGroup.extend({
loginMappings: z.array(loginMappingSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const sshHostGroup = await server.services.sshHostGroup.deleteSshHostGroup({
sshHostGroupId: req.params.sshHostGroupId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// TODO: audit log
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.DELETE_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname
// }
// }
// });
return sshHostGroup;
}
});
server.route({
method: "GET",
url: "/:sshHostGroupId/hosts",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId)
}),
querystring: z.object({
offset: z.coerce.number().min(0).max(100).default(0).describe(SSH_HOST_GROUPS.LIST_HOSTS.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(SSH_HOST_GROUPS.LIST_HOSTS.limit)
}),
response: {
200: z.object({
hosts: z.array(sanitizedSshHost),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
console.log("list hosts in group pre");
const { hosts, totalCount } = await server.services.sshHostGroup.listSshHostGroupHosts({
sshHostGroupId: req.params.sshHostGroupId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
console.log("list hosts in group post");
// TODO: audit logs
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.GET_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname
// }
// }
// });
return { hosts, totalCount };
}
});
server.route({
method: "POST",
url: "/:sshHostGroupId/hosts/:hostId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.sshHostGroupId),
hostId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.hostId)
}),
response: {
200: sanitizedSshHost.extend({
loginMappings: z.array(loginMappingSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
console.log("add host to group pre");
const host = await server.services.sshHostGroup.addHostToSshHostGroup({
sshHostGroupId: req.params.sshHostGroupId,
hostId: req.params.hostId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
console.log("add host to group post");
// TODO: audit logs
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.GET_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname
// }
// }
// });
return host;
}
});
server.route({
method: "DELETE",
url: "/:sshHostGroupId/hosts/:hostId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.sshHostGroupId),
hostId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.hostId)
}),
response: {
200: sanitizedSshHost.extend({
loginMappings: z.array(loginMappingSchema)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
console.log("remove host from group pre");
const host = await server.services.sshHostGroup.removeHostFromSshHostGroup({
sshHostGroupId: req.params.sshHostGroupId,
hostId: req.params.hostId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
console.log("remove host from group post");
// TODO: audit logs
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: host.projectId,
// event: {
// type: EventType.GET_SSH_HOST,
// metadata: {
// sshHostId: host.id,
// hostname: host.hostname
// }
// }
// });
return host;
}
});
};

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

@@ -22,7 +22,7 @@ export const getDefaultOnPremFeatures = () => {
samlSSO: false,
scim: false,
ldap: false,
groups: false,
groups: true,
status: null,
trial_end: null,
has_used_trial: true,

View File

@@ -35,7 +35,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
oidcSSO: false,
scim: false,
ldap: false,
groups: false,
groups: true,
status: null,
trial_end: null,
has_used_trial: true,

View File

@@ -52,7 +52,7 @@ export type TFeatureSet = {
secretAccessInsights: false;
scim: false;
ldap: false;
groups: false;
groups: true;
status: null;
trial_end: null;
has_used_trial: true;

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,166 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { groupBy, unique } from "@app/lib/fn";
import { ormify } from "@app/lib/knex";
export type TSshHostGroupDALFactory = ReturnType<typeof sshHostGroupDALFactory>;
export const sshHostGroupDALFactory = (db: TDbClient) => {
const sshHostGroupOrm = ormify(db, TableName.SshHostGroup);
const findSshHostGroupsWithLoginMappings = async (projectId: string, tx?: Knex) => {
try {
const rows = await (tx || db.replicaNode())(TableName.SshHostGroup)
.leftJoin(
TableName.SshHostLoginUser,
`${TableName.SshHostGroup}.id`,
`${TableName.SshHostLoginUser}.sshHostGroupId`
)
.leftJoin(
TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
)
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
.where(`${TableName.SshHostGroup}.projectId`, projectId)
.select(
db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"),
db.ref("projectId").withSchema(TableName.SshHostGroup),
db.ref("name").withSchema(TableName.SshHostGroup),
db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
db.ref("username").withSchema(TableName.Users),
db.ref("userId").withSchema(TableName.SshHostLoginUserMapping)
)
.orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc");
const hostsGrouped = groupBy(rows, (r) => r.sshHostGroupId);
return Object.values(hostsGrouped).map((hostRows) => {
const { sshHostGroupId, name } = hostRows[0];
const loginMappingGrouped = groupBy(
hostRows.filter((r) => r.loginUser),
(r) => r.loginUser
);
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean)
}
}));
return {
id: sshHostGroupId,
projectId,
name,
loginMappings
};
});
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupsWithLoginMappings` });
}
};
const findSshHostGroupByIdWithLoginMappings = async (sshHostGroupId: string, tx?: Knex) => {
try {
const rows = await (tx || db.replicaNode())(TableName.SshHostGroup)
.leftJoin(
TableName.SshHostLoginUser,
`${TableName.SshHostGroup}.id`,
`${TableName.SshHostLoginUser}.sshHostGroupId`
)
.leftJoin(
TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
)
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
.where(`${TableName.SshHostGroup}.id`, sshHostGroupId)
.select(
db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"),
db.ref("projectId").withSchema(TableName.SshHostGroup),
db.ref("name").withSchema(TableName.SshHostGroup),
db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
db.ref("username").withSchema(TableName.Users),
db.ref("userId").withSchema(TableName.SshHostLoginUserMapping)
);
if (rows.length === 0) return null;
const { sshHostGroupId: id, projectId, name } = rows[0];
const loginMappingGrouped = groupBy(
rows.filter((r) => r.loginUser),
(r) => r.loginUser
);
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean)
}
}));
return {
id,
projectId,
name,
loginMappings
};
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupByIdWithLoginMappings` });
}
};
const findAllSshHostsInGroup = async ({
sshHostGroupId,
offset = 0,
limit
}: {
sshHostGroupId: string;
offset?: number;
limit?: number;
}) => {
try {
const query = db
.replicaNode()(TableName.SshHostGroupMembership)
.where(`${TableName.SshHostGroupMembership}.sshHostGroupId`, sshHostGroupId)
.join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`)
.select(
db.ref("id").withSchema(TableName.SshHost),
db.ref("hostname").withSchema(TableName.SshHost),
db.ref("alias").withSchema(TableName.SshHost),
db.ref("createdAt").withSchema(TableName.SshHostGroupMembership).as("joinedGroupAt"),
db.raw(`count(*) OVER() as total_count`)
)
.offset(offset)
.orderBy(`${TableName.SshHost}.hostname`, "asc");
if (limit) {
void query.limit(limit);
}
const hosts = await query;
return {
hosts: hosts.map(({ id, hostname, alias }) => ({
id,
hostname,
alias
})),
// @ts-expect-error col select is raw and not strongly typed
totalCount: Number(hosts?.[0]?.total_count ?? 0)
};
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SshHostGroupMembership}: FindAllSshHostsInGroup` });
}
};
return {
findSshHostGroupsWithLoginMappings,
findSshHostGroupByIdWithLoginMappings,
findAllSshHostsInGroup,
...sshHostGroupOrm
};
};

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,353 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal";
import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal";
import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
import { TSshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal";
import { NotFoundError } from "@app/lib/errors";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { createSshLoginMappings } from "../ssh-host/ssh-host-fns";
import {
TAddHostToSshHostGroupDTO,
TCreateSshHostGroupDTO,
TDeleteSshHostGroupDTO,
TGetSshHostGroupDTO,
TListSshHostGroupHostsDTO,
TRemoveHostFromSshHostGroupDTO,
TUpdateSshHostGroupDTO
} from "./ssh-host-group-types";
type TSshHostGroupServiceFactoryDep = {
sshHostDAL: TSshHostDALFactory; // TODO: Pick
sshHostGroupDAL: Pick<
TSshHostGroupDALFactory,
| "create"
| "updateById"
| "findById"
| "deleteById"
| "transaction"
| "findSshHostGroupByIdWithLoginMappings"
| "findAllSshHostsInGroup"
>;
sshHostGroupMembershipDAL: TSshHostGroupMembershipDALFactory; // TODO: Pick
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
};
export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>;
export const sshHostGroupServiceFactory = ({
sshHostDAL,
sshHostGroupDAL,
sshHostGroupMembershipDAL,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
userDAL,
permissionService
}: TSshHostGroupServiceFactoryDep) => {
const createSshHostGroup = async ({
projectId,
name,
loginMappings,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TCreateSshHostGroupDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHostGroups);
const newSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => {
const sshHostGroup = await sshHostGroupDAL.create(
{
projectId,
name // TODO: check that this is unique across the whole org
},
tx
);
await createSshLoginMappings({
sshHostGroupId: sshHostGroup.id,
loginMappings,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
userDAL,
permissionService,
projectId,
actorAuthMethod,
actorOrgId,
tx
});
const newSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(
sshHostGroup.id,
tx
);
if (!newSshHostGroupWithLoginMappings) {
throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` });
}
return newSshHostGroupWithLoginMappings;
});
return newSshHostGroup;
};
const updateSshHostGroup = async ({
sshHostGroupId,
name,
loginMappings,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TUpdateSshHostGroupDTO) => {
const sshHostGroup = await sshHostGroupDAL.findById(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => {
await sshHostGroupDAL.updateById(
sshHostGroupId,
{
name
},
tx
);
if (loginMappings) {
await sshHostLoginUserDAL.delete({ sshHostGroupId: sshHostGroup.id }, tx);
if (loginMappings.length) {
await createSshLoginMappings({
sshHostGroupId: sshHostGroup.id,
loginMappings,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
userDAL,
permissionService,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
tx
});
}
}
const updatedSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(
sshHostGroup.id,
tx
);
if (!updatedSshHostGroupWithLoginMappings) {
throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` });
}
return updatedSshHostGroupWithLoginMappings;
});
return updatedSshHostGroup;
};
const getSshHostGroup = async ({
sshHostGroupId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TGetSshHostGroupDTO) => {
const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
return sshHostGroup;
};
const deleteSshHostGroup = async ({
sshHostGroupId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TDeleteSshHostGroupDTO) => {
const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SshHostGroups);
await sshHostGroupDAL.deleteById(sshHostGroupId);
return sshHostGroup;
};
const listSshHostGroupHosts = async ({
sshHostGroupId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TListSshHostGroupHostsDTO) => {
const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
// TODO: check
const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId });
console.log("hosts: ", hosts);
return { hosts, totalCount };
};
const addHostToSshHostGroup = async ({
sshHostGroupId,
hostId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TAddHostToSshHostGroupDTO) => {
const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
const host = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId);
if (!host) {
throw new NotFoundError({
message: `SSH host with ID ${hostId} not found`
});
}
if (sshHostGroup.projectId !== host.projectId) {
throw new NotFoundError({
message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
// TODO: look over permissioning
await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId });
return host;
};
const removeHostFromSshHostGroup = async ({
sshHostGroupId,
hostId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TRemoveHostFromSshHostGroupDTO) => {
const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` });
const host = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId);
if (!host) {
throw new NotFoundError({
message: `SSH host with ID ${hostId} not found`
});
}
if (sshHostGroup.projectId !== host.projectId) {
throw new NotFoundError({
message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: sshHostGroup.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
// TODO: look over permissioning
const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({
sshHostGroupId,
sshHostId: hostId
});
if (!sshHostGroupMembership) {
throw new NotFoundError({
message: `SSH host with ID ${hostId} not found in SSH host group with ID ${sshHostGroupId}`
});
}
await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id);
return host;
};
return {
createSshHostGroup,
getSshHostGroup,
deleteSshHostGroup,
updateSshHostGroup,
listSshHostGroupHosts,
addHostToSshHostGroup,
removeHostFromSshHostGroup
};
};

View File

@@ -0,0 +1,43 @@
import { TGenericPermission, TProjectPermission } from "@app/lib/types";
export type TCreateSshHostGroupDTO = {
name: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
} & TProjectPermission;
export type TUpdateSshHostGroupDTO = {
sshHostGroupId: string;
name?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
} & TGenericPermission;
export type TGetSshHostGroupDTO = {
sshHostGroupId: string;
} & TGenericPermission;
export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string;
} & TGenericPermission;
export type TListSshHostGroupHostsDTO = {
sshHostGroupId: string;
} & TGenericPermission;
export type TAddHostToSshHostGroupDTO = {
sshHostGroupId: string;
hostId: string;
} & TGenericPermission;
export type TRemoveHostFromSshHostGroupDTO = {
sshHostGroupId: string;
hostId: string;
} & TGenericPermission;

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,27 @@
import { Knex } from "knex";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal";
import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal";
import { TProjectPermission } from "@app/lib/types";
import { ActorAuthMethod } from "@app/services/auth/auth-type";
import { TUserDALFactory } from "@app/services/user/user-dal";
export type TListSshHostsDTO = Omit<TProjectPermission, "projectId">;
type LoginMapping = {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
};
export type TCreateSshHostDTO = {
hostname: string;
alias?: string;
userCertTtl: string;
hostCertTtl: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
loginMappings: LoginMapping[];
userSshCaId?: string;
hostSshCaId?: string;
} & TProjectPermission;
@@ -23,12 +32,7 @@ export type TUpdateSshHostDTO = {
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
loginMappings?: LoginMapping[];
} & Omit<TProjectPermission, "projectId">;
export type TGetSshHostDTO = {
@@ -48,3 +52,19 @@ export type TIssueSshHostHostCertDTO = {
sshHostId: string;
publicKey: string;
} & Omit<TProjectPermission, "projectId">;
type BaseCreateSshLoginMappingsDTO = {
loginMappings: LoginMapping[];
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

@@ -568,6 +568,9 @@ export const PROJECTS = {
LIST_SSH_HOSTS: {
projectId: "The ID of the project to list SSH hosts for."
},
LIST_SSH_HOST_GROUPS: {
projectId: "The ID of the project to list SSH host groups for."
},
LIST_SSH_CERTIFICATES: {
projectId: "The ID of the project to list SSH certificates for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.",
@@ -1382,6 +1385,39 @@ export const SSH_CERTIFICATE_TEMPLATES = {
}
};
export const SSH_HOST_GROUPS = {
GET: {
sshHostGroupId: "The ID of the SSH host group to get."
},
CREATE: {
projectId: "The ID of the project to create the SSH host group in.",
name: "The name of the SSH host group.",
loginMappings:
"A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project."
},
UPDATE: {
sshHostGroupId: "The ID of the SSH host group to update.",
name: "The name of the SSH host group to update to.",
loginMappings:
"A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project."
},
DELETE: {
sshHostGroupId: "The ID of the SSH host group to delete."
},
LIST_HOSTS: {
offset: "The offset to start from. If you enter 10, it will start from the 10th host",
limit: "The number of hosts to return."
},
ADD_HOST: {
sshHostGroupId: "The ID of the SSH host group to add the host to.",
hostId: "The ID of the SSH host to add to the SSH host group."
},
DELETE_HOST: {
sshHostGroupId: "The ID of the SSH host group to delete the host from.",
hostId: "The ID of the SSH host to delete from the SSH host group."
}
};
export const SSH_HOSTS = {
GET: {
sshHostId: "The ID of the SSH host to get."

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";
@@ -399,6 +402,8 @@ export const registerRoutes = async (
const sshHostDAL = sshHostDALFactory(db);
const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db);
const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db);
const sshHostGroupDAL = sshHostGroupDALFactory(db);
const sshHostGroupMembershipDAL = sshHostGroupMembershipDALFactory(db);
const kmsDAL = kmskeyDALFactory(db);
const internalKmsDAL = internalKmsDALFactory(db);
@@ -849,6 +854,16 @@ export const registerRoutes = async (
kmsService
});
const sshHostGroupService = sshHostGroupServiceFactory({
sshHostDAL,
sshHostGroupDAL,
sshHostGroupMembershipDAL,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
userDAL,
permissionService
});
const certificateAuthorityService = certificateAuthorityServiceFactory({
certificateAuthorityDAL,
certificateAuthorityCertDAL,
@@ -1018,6 +1033,7 @@ export const registerRoutes = async (
sshCertificateDAL,
sshCertificateTemplateDAL,
sshHostDAL,
sshHostGroupDAL,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL,
keyStore,
@@ -1668,6 +1684,7 @@ export const registerRoutes = async (
sshCertificateAuthority: sshCertificateAuthorityService,
sshCertificateTemplate: sshCertificateTemplateService,
sshHost: sshHostService,
sshHostGroup: sshHostGroupService,
certificateAuthority: certificateAuthorityService,
certificateTemplate: certificateTemplateService,
certificateAuthorityCrl: certificateAuthorityCrlService,

View File

@@ -14,6 +14,7 @@ import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-s
import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema";
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema";
import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema";
import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
@@ -650,4 +651,38 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
return { hosts };
}
});
server.route({
method: "GET",
url: "/:projectId/ssh-host-groups",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOST_GROUPS.projectId)
}),
response: {
200: z.object({
groups: z.array(
sanitizedSshHostGroup.extend({
loginMappings: z.array(loginMappingSchema)
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const groups = await server.services.project.listProjectSshHostGroups({
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actor: req.permission.type,
projectId: req.params.projectId
});
return { groups };
}
});
};

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";
@@ -136,12 +137,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">;
@@ -193,6 +194,7 @@ export const projectServiceFactory = ({
sshCertificateDAL,
sshCertificateTemplateDAL,
sshHostDAL,
sshHostGroupDAL,
keyStore,
kmsService,
projectBotDAL,
@@ -1143,6 +1145,32 @@ export const projectServiceFactory = ({
return allowedHosts;
};
/**
* Return list of SSH host groups for project
*/
const listProjectSshHostGroups = async ({
actorId,
actorOrgId,
actorAuthMethod,
actor,
projectId
}: TListProjectSshHostsDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups);
const sshHostGroups = await sshHostGroupDAL.findSshHostGroupsWithLoginMappings(projectId);
return sshHostGroups;
};
/**
* Return list of SSH certificates for project
*/
@@ -1665,6 +1693,7 @@ export const projectServiceFactory = ({
listProjectCertificateTemplates,
listProjectSshCas,
listProjectSshHosts,
listProjectSshHostGroups,
listProjectSshCertificates,
listProjectSshCertificateTemplates,
updateVersionLimit,

View File

@@ -298,6 +298,10 @@ export const ROUTE_PATHS = Object.freeze({
SshCaByIDPage: setRoute(
"/ssh/$projectId/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId"
),
SshGroupDetailsByIDPage: setRoute(
"/ssh/$projectId/ssh-groups/$groupId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/groups/$groupId"
)
},
Public: {

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

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

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

View File

@@ -0,0 +1,61 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/query-keys";
import {
TCreateSshHostGroupDTO,
TDeleteSshHostGroupDTO,
TSshHostGroup,
TUpdateSshHostGroupDTO
} from "./types";
export const useCreateSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<TSshHostGroup, object, TCreateSshHostGroupDTO>({
mutationFn: async (body) => {
const { data: hostGroup } = await apiRequest.post("/api/v1/ssh/host-groups", body);
return hostGroup;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
}
});
};
export const useUpdateSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<TSshHostGroup, object, TUpdateSshHostGroupDTO>({
mutationFn: async ({ sshHostGroupId, ...body }) => {
const { data: hostGroup } = await apiRequest.patch(
`/api/v1/ssh/host-groups/${sshHostGroupId}`,
body
);
return hostGroup;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
}
});
};
export const useDeleteSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<TSshHostGroup, object, TDeleteSshHostGroupDTO>({
mutationFn: async ({ sshHostGroupId }) => {
const { data: hostGroup } = await apiRequest.delete(
`/api/v1/ssh/host-groups/${sshHostGroupId}`
);
return hostGroup;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
}
});
};

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TSshHostGroup } from "./types";
export const sshHostGroupKeys = {
getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"]
};
export const useGetSshHostGroupById = (sshHostGroupId: string) => {
return useQuery({
queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId),
queryFn: async () => {
const { data: sshHostGroup } = await apiRequest.get<TSshHostGroup>(
`/api/v1/ssh/host-groups/${sshHostGroupId}`
);
return sshHostGroup;
},
enabled: Boolean(sshHostGroupId)
});
};

View File

@@ -0,0 +1,37 @@
export type TSshHostGroup = {
id: string;
projectId: string;
name: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
};
export type TCreateSshHostGroupDTO = {
projectId: string;
name: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
};
export type TUpdateSshHostGroupDTO = {
sshHostGroupId: string;
name?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
};
export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string;
};

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 { ProjectSlackConfig } from "../workflowIntegrations/types";
@@ -870,6 +871,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => {
});
};
export const useListWorkspaceSshHostGroups = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId),
queryFn: async () => {
const {
data: { groups }
} = await apiRequest.get<{ groups: TSshHostGroup[] }>(
`/api/v2/workspace/${projectId}/ssh-host-groups`
);
return groups;
},
enabled: Boolean(projectId)
});
};
export const useListWorkspaceSshCertificateTemplates = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId),

View File

@@ -60,6 +60,8 @@ export const workspaceKeys = {
allWorkspaceSshCertificates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificates"] as const,
getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const,
getWorkspaceSshHostGroups: (projectId: string) =>
[{ projectId }, "workspace-ssh-host-groups"] as const,
specificWorkspaceSshCertificates: ({
offset,
limit,

View File

@@ -0,0 +1,147 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
PageHeader,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useDeleteSshHostGroup, useGetSshHostGroupById } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
const Page = () => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const projectId = currentWorkspace?.id || "";
const groupId = useParams({
from: ROUTE_PATHS.Ssh.SshGroupDetailsByIDPage.id,
select: (el) => el.groupId
});
const { data } = useGetSshHostGroupById(groupId);
const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHostGroup",
"deleteSshHostGroup"
] as const);
const onRemoveSshGroupSubmit = async (groupIdToDelete: string) => {
try {
if (!projectId) return;
await deleteSshHostGroup({ sshHostGroupId: groupIdToDelete });
createNotification({
text: "Successfully deleted SSH group",
type: "success"
});
handlePopUpClose("deleteSshHostGroup");
navigate({
to: `/${ProjectType.SSH}/$projectId/overview` as const,
params: {
projectId
}
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH group",
type: "error"
});
}
};
return (
<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
})
}
disabled={!isAllowed}
>
Delete SSH Group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<SshHostGroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full">
<SshHostsSection groupId={groupId} />
</div>
</div>
</div>
)}
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHostGroup.isOpen}
title="Are you sure want to remove the SSH group from the project?"
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId)
}
/>
</div>
);
};
export const SshGroupDetailsByIDPage = () => {
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,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { SshGroupDetailsByIDPage } from './SshGroupDetailsByIDPage'
export const Route = createFileRoute(
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId',
)({
component: SshGroupDetailsByIDPage,
})

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>

View File

@@ -0,0 +1,175 @@
import { useState } from "react";
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useDebounce, useResetPageHelper } from "@app/hooks";
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshHostGroupHosts"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["sshHostGroupHosts"]>,
state?: boolean
) => void;
};
export const SshHostGroupHostsModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const [debouncedSearch] = useDebounce(searchMemberFilter);
const popUpData = popUp?.addGroupMembers?.data as {
groupId: string;
slug: string;
};
const offset = (page - 1) * perPage;
const { data, isPending } = useListGroupUsers({
id: popUpData?.groupId,
groupSlug: popUpData?.slug,
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedUsers.NON_MEMBERS
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
const handleAddHost = async (hostId: string) => {
try {
if (!popUpData?.slug) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
// await addUserToGroupMutateAsync({
// groupId: popUpData.groupId,
// username,
// slug: popUpData.slug
// });
createNotification({
text: "Successfully assigned host to the SSH host group",
type: "success"
});
} catch {
createNotification({
text: "Failed to assign host to the SSH host group",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshHostGroupHosts?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("sshHostGroupHosts", isOpen);
}}
>
<ModalContent title="Add Hosts to Group">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
{!isPending &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddMember(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.users?.length && (
<EmptyState
title={
debouncedSearch ? "No users match search" : "All users are already in the group"
}
icon={faUsers}
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,396 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateSshHostGroup,
useGetSshHostGroupById,
useGetWorkspaceUsers,
useListWorkspaceSshHostGroups,
useUpdateSshHostGroup
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshHostGroup"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, state?: boolean) => void;
};
const schema = z
.object({
name: z.string().trim(),
loginMappings: z
.object({
loginUser: z.string().trim().min(1),
allowedPrincipals: z.array(z.string().trim()).default([])
})
.array()
.default([])
})
.required();
export type FormData = z.infer<typeof schema>;
export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id);
const { data: members = [] } = useGetWorkspaceUsers(projectId);
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
const { data: sshHostGroup } = useGetSshHostGroupById(
(popUp?.sshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId || ""
);
const { mutateAsync: createMutateAsync } = useCreateSshHostGroup();
const { mutateAsync: updateMutateAsync } = useUpdateSshHostGroup();
const {
control,
handleSubmit,
reset,
getValues,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
loginMappings: []
}
});
const loginMappingsFormFields = useFieldArray({
control,
name: "loginMappings"
});
useEffect(() => {
if (sshHostGroup) {
reset({
name: sshHostGroup.name,
loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: allowedPrincipals.usernames
}))
});
setExpandedMappings(
Object.fromEntries(sshHostGroup.loginMappings.map((_, index) => [index, false]))
);
} else {
reset({
name: "",
loginMappings: []
});
}
}, [sshHostGroup]);
const onFormSubmit = async ({ name, loginMappings }: FormData) => {
try {
if (!projectId) return;
// check if there is already a different host group with the same name
const existingNames =
sshHostGroups?.filter((h) => h.id !== sshHostGroup?.id).map((h) => h.name) || [];
if (existingNames.includes(name.trim())) {
createNotification({
text: "A host group with this name already exists.",
type: "error"
});
return;
}
if (sshHostGroup) {
await updateMutateAsync({
sshHostGroupId: sshHostGroup.id,
name,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
});
} else {
await createMutateAsync({
projectId,
name,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
});
}
reset();
handlePopUpToggle("sshHostGroup", false);
createNotification({
text: `Successfully ${sshHostGroup ? "updated" : "created"} SSH host group`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${sshHostGroup ? "update" : "create"} SSH host group`,
type: "error"
});
}
};
const toggleMapping = (index: number) => {
setExpandedMappings((prev) => ({
...prev,
[index]: !prev[index]
}));
};
return (
<Modal
isOpen={popUp?.sshHostGroup?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("sshHostGroup", isOpen);
}}
>
<ModalContent title={`${sshHostGroup ? "View" : "Add"} SSH host group`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{sshHostGroup && (
<FormControl label="SSH Host Group ID">
<Input value={sshHostGroup.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="production" />
</FormControl>
)}
/>
<div className="mb-4 flex items-center justify-between">
<FormLabel label="Login Mappings" />
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
setExpandedMappings((prev) => ({
...prev,
[newIndex]: true
}));
}}
>
Add Login Mapping
</Button>
</div>
<div className="mb-4 flex flex-col space-y-4">
{loginMappingsFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div
key={metadataFieldId}
className="flex flex-col space-y-2 rounded-md border border-mineshaft-600 p-4"
>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
className="flex cursor-pointer items-center py-1 text-sm text-mineshaft-200"
onClick={() => toggleMapping(i)}
>
<FontAwesomeIcon
icon={expandedMappings[i] ? faChevronDown : faChevronRight}
className="mr-4"
size="sm"
/>
<Controller
control={control}
name={`loginMappings.${i}.loginUser`}
render={({ field }) => (
<span className="text-sm font-medium leading-tight">
{field.value || "New Login Mapping"}
</span>
)}
/>
</button>
<IconButton
ariaLabel="delete login mapping"
variant="plain"
onClick={() => loginMappingsFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
{expandedMappings[i] && (
<>
<div>
<span className="text-xs text-mineshaft-400">Login User</span>
<Controller
control={control}
name={`loginMappings.${i}.loginUser`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input
{...field}
placeholder="ec2-user"
onChange={(e) => {
const newValue = e.target.value;
const loginMappings = getValues("loginMappings");
const isDuplicate = loginMappings.some(
(mapping, index) => index !== i && mapping.loginUser === newValue
);
if (isDuplicate) {
createNotification({
text: "This login user already exists",
type: "error"
});
return;
}
field.onChange(e);
}}
/>
</FormControl>
)}
/>
</div>
<div className="flex flex-col space-y-2">
<div className="mb-2 mt-4 flex items-center justify-between">
<FormLabel
label="Allowed Principals"
className="text-xs text-mineshaft-400"
/>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
</div>
<Controller
control={control}
name={`loginMappings.${i}.allowedPrincipals`}
render={({ field: { value = [], onChange }, fieldState: { error } }) => (
<div className="flex flex-col space-y-2">
{(value.length === 0 ? [""] : value).map(
(principal: string, principalIndex: number) => (
<div
key={`${metadataFieldId}-principal-${principal}`}
className="flex items-center space-x-2"
>
<div className="flex-1">
<Select
value={principal}
onValueChange={(newValue) => {
if (value.includes(newValue)) {
createNotification({
text: "This principal is already added",
type: "error"
});
return;
}
const newPrincipals = [...value];
newPrincipals[principalIndex] = newValue;
onChange(newPrincipals);
}}
placeholder="Select a member"
className="w-full"
>
{members.map((member) => (
<SelectItem
key={member.user.id}
value={member.user.username}
>
{member.user.username}
</SelectItem>
))}
</Select>
</div>
<IconButton
size="sm"
ariaLabel="delete principal"
variant="plain"
className="h-9"
onClick={() => {
const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex
);
onChange(newPrincipals);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
)
)}
{error && <span className="text-sm text-red-500">{error.message}</span>}
</div>
)}
/>
</div>
</>
)}
</div>
))}
</div>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.sshHostGroup?.data ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshHostGroup", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,74 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useDeleteSshHostGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshHostGroupModal } from "./SshHostGroupModal";
import { SshHostGroupsTable } from "./SshHostGroupsTable";
export const SshHostGroupsSection = () => {
const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHostGroup",
"deleteSshHostGroup"
] as const);
const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => {
try {
const hostGroup = await deleteSshHostGroup({ sshHostGroupId });
createNotification({
text: `Successfully deleted SSH host group: ${hostGroup.name}`,
type: "success"
});
handlePopUpClose("deleteSshHostGroup");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH host group",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Host Groups</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.SshHosts}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshHostGroup")}
isDisabled={!isAllowed}
>
Add Group
</Button>
)}
</ProjectPermissionCan>
</div>
<SshHostGroupsTable handlePopUpOpen={handlePopUpOpen} />
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHostGroup.isOpen}
title="Are you sure you want to remove the SSH host group?"
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostGroupSubmit(
(popUp?.deleteSshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId
)
}
/>
</div>
);
};

View File

@@ -0,0 +1,157 @@
import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useListWorkspaceSshHostGroups } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteSshHostGroup", "sshHostGroup"]>,
data?: object
) => void;
};
export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspaceSshHostGroups(currentWorkspace?.id || "");
return (
<div>
<TableContainer>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Name</Th>
<Th>Hosts</Th>
<Th>Login User - Authorized Principals Mapping</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-ssh-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((group) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ssh-host-group-${group.id}`}
onClick={() =>
navigate({
to: `/${ProjectType.SSH}/$projectId/ssh-groups/$sshGroupId` as const,
params: {
projectId: currentWorkspace.id,
sshGroupId: group.id
}
})
}
>
<Td>{group.name}</Td>
<Td>-</Td>
<Td>
{group.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span>
) : (
group.loginMappings.map(({ loginUser, allowedPrincipals }) => (
<div key={`${group.id}-${loginUser}`} className="mb-2">
<div className="text-mineshaft-200">{loginUser}</div>
{allowedPrincipals.usernames.map((username) => (
<div key={`${group.id}-${loginUser}-${username}`} className="ml-4">
{username}
</div>
))}
</div>
))
)}
</Td>
<Td className="text-right align-middle">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshHostGroup", {
sshHostGroupId: group.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit SSH host group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteSshHostGroup", {
sshHostGroupId: group.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete SSH host group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState title="No SSH host groups have been created" icon={faServer} />
)}
</TableContainer>
</div>
);
};

View File

@@ -63,7 +63,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
return (
<div>
<TableContainer>
<Table>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Alias</Th>

View File

@@ -1 +1,2 @@
export { SshHostGroupsSection } from "./SshHostGroupsSection";
export { SshHostsSection } from "./SshHostsSection";

View File

@@ -107,6 +107,7 @@ import { Route as projectRoleDetailsBySlugPageRouteCertManagerImport } from './p
import { Route as certManagerPkiCollectionDetailsByIDPageRoutesImport } from './pages/cert-manager/PkiCollectionDetailsByIDPage/routes'
import { Route as projectMemberDetailsByIDPageRouteCertManagerImport } from './pages/project/MemberDetailsByIDPage/route-cert-manager'
import { Route as projectIdentityDetailsByIDPageRouteCertManagerImport } from './pages/project/IdentityDetailsByIDPage/route-cert-manager'
import { Route as sshSshGroupDetailsByIDPageRouteImport } from './pages/ssh/SshGroupDetailsByIDPage/route'
import { Route as sshSshCaByIDPageRouteImport } from './pages/ssh/SshCaByIDPage/route'
import { Route as secretManagerSecretDashboardPageRouteImport } from './pages/secret-manager/SecretDashboardPage/route'
import { Route as secretManagerIntegrationsSelectIntegrationAuthPageRouteImport } from './pages/secret-manager/integrations/SelectIntegrationAuthPage/route'
@@ -1001,6 +1002,13 @@ const projectIdentityDetailsByIDPageRouteCertManagerRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
const sshSshGroupDetailsByIDPageRouteRoute =
sshSshGroupDetailsByIDPageRouteImport.update({
id: '/ssh-host-groups/$sshHostGroupId',
path: '/ssh-host-groups/$sshHostGroupId',
getParentRoute: () => sshLayoutRoute,
} as any)
const sshSshCaByIDPageRouteRoute = sshSshCaByIDPageRouteImport.update({
id: '/ca/$caId',
path: '/ca/$caId',
@@ -2379,6 +2387,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof sshSshCaByIDPageRouteImport
parentRoute: typeof sshLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId': {
id: '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId'
path: '/ssh-host-groups/$sshHostGroupId'
fullPath: '/ssh/$projectId/ssh-host-groups/$sshHostGroupId'
preLoaderRoute: typeof sshSshGroupDetailsByIDPageRouteImport
parentRoute: typeof sshLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': {
id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId'
path: '/identities/$identityId'
@@ -3546,6 +3561,7 @@ interface sshLayoutRouteChildren {
sshSettingsPageRouteRoute: typeof sshSettingsPageRouteRoute
projectAccessControlPageRouteSshRoute: typeof projectAccessControlPageRouteSshRoute
sshSshCaByIDPageRouteRoute: typeof sshSshCaByIDPageRouteRoute
sshSshGroupDetailsByIDPageRouteRoute: typeof sshSshGroupDetailsByIDPageRouteRoute
projectIdentityDetailsByIDPageRouteSshRoute: typeof projectIdentityDetailsByIDPageRouteSshRoute
projectMemberDetailsByIDPageRouteSshRoute: typeof projectMemberDetailsByIDPageRouteSshRoute
projectRoleDetailsBySlugPageRouteSshRoute: typeof projectRoleDetailsBySlugPageRouteSshRoute
@@ -3558,6 +3574,7 @@ const sshLayoutRouteChildren: sshLayoutRouteChildren = {
sshSettingsPageRouteRoute: sshSettingsPageRouteRoute,
projectAccessControlPageRouteSshRoute: projectAccessControlPageRouteSshRoute,
sshSshCaByIDPageRouteRoute: sshSshCaByIDPageRouteRoute,
sshSshGroupDetailsByIDPageRouteRoute: sshSshGroupDetailsByIDPageRouteRoute,
projectIdentityDetailsByIDPageRouteSshRoute:
projectIdentityDetailsByIDPageRouteSshRoute,
projectMemberDetailsByIDPageRouteSshRoute:
@@ -3866,6 +3883,7 @@ export interface FileRoutesByFullPath {
'/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
'/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute
'/ssh/$projectId/ca/$caId': typeof sshSshCaByIDPageRouteRoute
'/ssh/$projectId/ssh-host-groups/$sshHostGroupId': typeof sshSshGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4042,6 +4060,7 @@ export interface FileRoutesByTo {
'/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
'/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute
'/ssh/$projectId/ca/$caId': typeof sshSshCaByIDPageRouteRoute
'/ssh/$projectId/ssh-host-groups/$sshHostGroupId': typeof sshSshGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4236,6 +4255,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId': typeof sshSshCaByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId': typeof sshSshGroupDetailsByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4422,6 +4442,7 @@ export interface FileRouteTypes {
| '/secret-manager/$projectId/integrations/select-integration-auth'
| '/secret-manager/$projectId/secrets/$envSlug'
| '/ssh/$projectId/ca/$caId'
| '/ssh/$projectId/ssh-host-groups/$sshHostGroupId'
| '/cert-manager/$projectId/identities/$identityId'
| '/cert-manager/$projectId/members/$membershipId'
| '/cert-manager/$projectId/pki-collections/$collectionId'
@@ -4597,6 +4618,7 @@ export interface FileRouteTypes {
| '/secret-manager/$projectId/integrations/select-integration-auth'
| '/secret-manager/$projectId/secrets/$envSlug'
| '/ssh/$projectId/ca/$caId'
| '/ssh/$projectId/ssh-host-groups/$sshHostGroupId'
| '/cert-manager/$projectId/identities/$identityId'
| '/cert-manager/$projectId/members/$membershipId'
| '/cert-manager/$projectId/pki-collections/$collectionId'
@@ -4789,6 +4811,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth'
| '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug'
| '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId'
| '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId'
@@ -5321,6 +5344,7 @@ export const routeTree = rootRoute
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/settings",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/identities/$identityId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/members/$membershipId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/roles/$roleSlug"
@@ -5554,6 +5578,10 @@ export const routeTree = rootRoute
"filePath": "ssh/SshCaByIDPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout"
},
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId": {
"filePath": "ssh/SshGroupDetailsByIDPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout"
},
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId": {
"filePath": "project/IdentityDetailsByIDPage/route-cert-manager.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout"

View File

@@ -312,6 +312,7 @@ const sshRoutes = route("/ssh/$projectId", [
route("/certificates", "ssh/SshCertsPage/route.tsx"),
route("/cas", "ssh/SshCasPage/route.tsx"),
route("/ca/$caId", "ssh/SshCaByIDPage/route.tsx"),
route("/ssh-host-groups/$sshHostGroupId", "ssh/SshGroupDetailsByIDPage/route.tsx"),
route("/settings", "ssh/SettingsPage/route.tsx"),
route("/access-management", "project/AccessControlPage/route-ssh.tsx"),
route("/roles/$roleSlug", "project/RoleDetailsBySlugPage/route-ssh.tsx"),