Merge branch 'main' into feat/addGroupsToSshHosts

This commit is contained in:
carlosmonastyrski
2025-05-05 14:51:01 -03:00
393 changed files with 17201 additions and 2453 deletions
+156 -21
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,13 +15,16 @@ 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,
@@ -46,26 +51,79 @@ 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`)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.UserGroupMembership}.groupId`,
`${TableName.SshHostLoginUserMapping}.groupId`
)
.whereIn(`${TableName.SshHost}.projectId`, projectIds)
.andWhere((bd) => {
void bd
.where(`${TableName.SshHostLoginUserMapping}.userId`, userId)
.orWhere(`${TableName.UserGroupMembership}.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,
@@ -112,21 +170,61 @@ 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`)
.leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`)
.select(
db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership),
db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
db.ref("username").withSchema(TableName.Users),
db.ref("slug").withSchema(TableName.Groups).as("groupSlug")
)
.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),
groups: unique(entries.map((e) => e.groupSlug)).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),
groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean)
},
source: LoginMappingSource.HOST_GROUP
}));
return {
@@ -136,7 +234,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
projectId,
userCertTtl,
hostCertTtl,
loginMappings,
loginMappings: [...directMappings, ...groupMappings],
userSshCaId,
hostSshCaId
};
@@ -177,17 +275,54 @@ 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),
groups: unique(entries.map((e) => e.groupSlug)).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`)
.leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`)
.where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId)
.select(
db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
db.ref("username").withSchema(TableName.Users),
db.ref("slug").withSchema(TableName.Groups).as("groupSlug")
);
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),
groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean)
},
source: LoginMappingSource.HOST_GROUP
}));
return {
@@ -197,7 +332,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
alias,
userCertTtl,
hostCertTtl,
loginMappings,
loginMappings: [...directMappings, ...groupMappings],
userSshCaId,
hostSshCaId
};
@@ -0,0 +1,122 @@
import { Knex } from "knex";
import { ActionProjectType } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors";
import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "../permission/project-permission";
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,
groupDAL,
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 && 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 (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) {
const projectGroups = await groupDAL.findGroupsByProjectId(projectId);
const groups = projectGroups.filter((g) => allowedPrincipals.groups?.includes(g.slug));
if (groups.length !== allowedPrincipals.groups?.length) {
throw new BadRequestError({
message: `Invalid group slugs: ${allowedPrincipals.groups
.filter((g) => !projectGroups.some((pg) => pg.slug === g))
.join(", ")}`
});
}
for await (const group of groups) {
// check that each group has access to the SSH project and have read access to hosts
const hasPermission = await permissionService.checkGroupProjectPermission({
groupId: group.id,
projectId,
checkPermissions: [ProjectPermissionSshHostActions.Read, ProjectPermissionSub.SshHosts]
});
if (!hasPermission) {
throw new BadRequestError({
message: `Group ${group.slug} does not have access to the SSH project`
});
}
}
await sshHostLoginUserMappingDAL.insertMany(
groups.map((group) => ({
sshHostLoginUserId: sshHostLoginUser.id,
groupId: group.id
})),
tx
);
}
}
};
if (outerTx) {
return processCreation(outerTx);
}
return sshHostLoginUserDAL.transaction(processCreation);
};
@@ -28,6 +28,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,
@@ -211,91 +212,19 @@ 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 && 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 (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) {
const projectGroups = await groupDAL.findGroupsByProjectId(projectId);
const groups = projectGroups.filter((g) => allowedPrincipals.groups?.includes(g.slug));
if (groups.length !== allowedPrincipals.groups?.length) {
throw new BadRequestError({
message: `Invalid group slugs: ${allowedPrincipals.groups
.filter((g) => !projectGroups.some((pg) => pg.slug === g))
.join(", ")}`
});
}
for await (const group of groups) {
// check that each group has access to the SSH project and have read access to hosts
const hasPermission = await permissionService.checkGroupProjectPermission({
groupId: group.id,
projectId,
checkPermissions: [ProjectPermissionSshHostActions.Read, ProjectPermissionSub.SshHosts]
});
if (!hasPermission) {
throw new BadRequestError({
message: `Group ${group.slug} does not have access to the SSH project`
});
}
}
await sshHostLoginUserMappingDAL.insertMany(
groups.map((group) => ({
sshHostLoginUserId: sshHostLoginUser.id,
groupId: group.id
})),
tx
);
}
}
await createSshLoginMappings({
sshHostId: host.id,
loginMappings,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
groupDAL,
userDAL,
permissionService,
projectId,
actorAuthMethod,
actorOrgId,
tx
});
const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx);
if (!newSshHostWithLoginMappings) {
@@ -354,90 +283,19 @@ 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 && 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
);
}
if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) {
const groups = await groupDAL.findGroupsByProjectId(host.projectId);
const foundGroupSlugs = new Set(groups.map((g) => g.slug));
for (const slug of allowedPrincipals.groups) {
if (!foundGroupSlugs.has(slug)) {
throw new BadRequestError({
message: `Invalid group slug: ${slug}`
});
}
}
for await (const group of groups) {
// check that each group has access to the SSH project and have read access to hosts
const hasPermission = await permissionService.checkGroupProjectPermission({
groupId: group.id,
projectId: host.projectId,
checkPermissions: [ProjectPermissionSshHostActions.Read, ProjectPermissionSub.SshHosts]
});
if (!hasPermission) {
throw new BadRequestError({
message: `Group ${group.slug} does not have access to the SSH project`
});
}
}
await sshHostLoginUserMappingDAL.insertMany(
groups.map((group) => ({
sshHostLoginUserId: sshHostLoginUser.id,
groupId: group.id
})),
tx
);
}
}
await createSshLoginMappings({
sshHostId: host.id,
loginMappings,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
groupDAL,
userDAL,
permissionService,
projectId: host.projectId,
actorAuthMethod,
actorOrgId,
tx
});
}
}
@@ -546,8 +404,8 @@ export const sshHostServiceFactory = ({
const mapping = host.loginMappings.find(
(m) =>
m.loginUser === loginUser &&
(m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) ||
m.allowedPrincipals.groups.some((allowed) => userGroupSlugs.includes(allowed)))
(m.allowedPrincipals.usernames?.some((allowed) => internalPrincipals.includes(allowed)) ||
m.allowedPrincipals.groups?.some((allowed) => userGroupSlugs.includes(allowed)))
);
if (!mapping) {
@@ -1,19 +1,35 @@
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";
import { TGroupDALFactory } from "../group/group-dal";
export type TListSshHostsDTO = Omit<TProjectPermission, "projectId">;
export type TLoginMapping = {
loginUser: string;
allowedPrincipals: {
usernames?: string[];
groups?: 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[];
groups?: string[];
};
}[];
loginMappings: TLoginMapping[];
userSshCaId?: string;
hostSshCaId?: string;
} & TProjectPermission;
@@ -24,13 +40,7 @@ export type TUpdateSshHostDTO = {
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames?: string[];
groups?: string[];
};
}[];
loginMappings?: TLoginMapping[];
} & Omit<TProjectPermission, "projectId">;
export type TGetSshHostDTO = {
@@ -50,3 +60,20 @@ 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" | "checkGroupProjectPermission">;
groupDAL: Pick<TGroupDALFactory, "findGroupsByProjectId">;
projectId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
tx?: Knex;
};
export type TCreateSshLoginMappingsDTO =
| (BaseCreateSshLoginMappingsDTO & { sshHostId: string; sshHostGroupId?: undefined })
| (BaseCreateSshLoginMappingsDTO & { sshHostGroupId: string; sshHostId?: undefined });