Update db schema for ssh login mappings

This commit is contained in:
Tuan Dang
2025-04-10 10:50:23 -07:00
parent 9baab63b29
commit 81331ec4d1
21 changed files with 516 additions and 266 deletions
@@ -11,52 +11,49 @@ export type TSshHostDALFactory = ReturnType<typeof sshHostDALFactory>;
export const sshHostDALFactory = (db: TDbClient) => {
const sshHostOrm = ormify(db, TableName.SshHost);
const findSshHostsWithPrincipalsAcrossProjects = async (projectIds: string[], principals: string[], tx?: Knex) => {
const findSshHostsWithPrincipalsAcrossProjects = async (projectIds: string[], userId: string, tx?: Knex) => {
try {
const matchingSshHosts = await (tx || db.replicaNode())(TableName.SshHost)
const user = await (tx || db.replicaNode())(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)
.leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`)
.leftJoin(
TableName.SshHostLoginMapping,
`${TableName.SshHost}.id`,
`${TableName.SshHostLoginMapping}.sshHostId`
TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
)
.whereIn(`${TableName.SshHost}.projectId`, projectIds)
.whereRaw(`"${TableName.SshHostLoginMapping}"."allowedPrincipals" && ?::text[]`, [principals])
.andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId)
.select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
db.ref("projectId").withSchema(TableName.SshHost),
db.ref("hostname").withSchema(TableName.SshHost),
db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping),
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(matchingSshHosts, (r) => r.sshHostId);
const grouped = groupBy(rows, (r) => r.sshHostId);
return Object.values(grouped).map((hostRows) => {
const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0];
const loginMappingGrouped = groupBy(
hostRows.filter((r) => r.loginUser),
(r) => r.loginUser
);
const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser);
const loginMappings = Object.entries(loginMappingGrouped)
.map(([loginUser, entries]) => {
const filteredPrincipals = unique(entries.flatMap((entry) => entry.allowedPrincipals ?? [])).filter(
(principal) => principals.includes(principal)
);
if (filteredPrincipals.length === 0) return null;
return {
loginUser,
allowedPrincipals: filteredPrincipals
};
})
.filter(Boolean) as { loginUser: string; allowedPrincipals: string[] }[];
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({
loginUser,
allowedPrincipals: {
usernames: [user.username]
}
}));
return {
id: sshHostId,
@@ -77,11 +74,13 @@ export const sshHostDALFactory = (db: TDbClient) => {
const findSshHostsWithLoginMappings = async (projectId: string, tx?: Knex) => {
try {
const rows = await (tx || db.replicaNode())(TableName.SshHost)
.leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`)
.leftJoin(
TableName.SshHostLoginMapping,
`${TableName.SshHost}.id`,
`${TableName.SshHostLoginMapping}.sshHostId`
TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
)
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
.where(`${TableName.SshHost}.projectId`, projectId)
.select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
@@ -89,8 +88,9 @@ export const sshHostDALFactory = (db: TDbClient) => {
db.ref("hostname").withSchema(TableName.SshHost),
db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping),
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)
)
@@ -107,13 +107,15 @@ export const sshHostDALFactory = (db: TDbClient) => {
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: unique(entries.flatMap((entry) => entry.allowedPrincipals ?? []))
allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean)
}
}));
return {
id: sshHostId,
projectId,
hostname,
projectId,
userCertTtl,
hostCertTtl,
loginMappings,
@@ -129,11 +131,13 @@ export const sshHostDALFactory = (db: TDbClient) => {
const findSshHostByIdWithLoginMappings = async (sshHostId: string, tx?: Knex) => {
try {
const rows = await (tx || db.replicaNode())(TableName.SshHost)
.leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`)
.leftJoin(
TableName.SshHostLoginMapping,
`${TableName.SshHost}.id`,
`${TableName.SshHostLoginMapping}.sshHostId`
TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
)
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
.where(`${TableName.SshHost}.id`, sshHostId)
.select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
@@ -141,8 +145,9 @@ export const sshHostDALFactory = (db: TDbClient) => {
db.ref("hostname").withSchema(TableName.SshHost),
db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping),
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)
);
@@ -158,7 +163,9 @@ export const sshHostDALFactory = (db: TDbClient) => {
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: unique(entries.flatMap((entry) => entry.allowedPrincipals ?? []))
allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean)
}
}));
return {
@@ -178,8 +185,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
return {
...sshHostOrm,
findSshHostsWithPrincipalsAcrossProjects,
findSshHostsWithLoginMappings,
findSshHostsWithPrincipalsAcrossProjects,
findSshHostByIdWithLoginMappings
};
};
@@ -1,10 +0,0 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSshHostLoginMappingDALFactory = ReturnType<typeof sshHostLoginMappingDALFactory>;
export const sshHostLoginMappingDALFactory = (db: TDbClient) => {
const sshHostLoginMappingOrm = ormify(db, TableName.SshHostLoginMapping);
return sshHostLoginMappingOrm;
};
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSshHostLoginUserMappingDALFactory = ReturnType<typeof sshHostLoginUserMappingDALFactory>;
export const sshHostLoginUserMappingDALFactory = (db: TDbClient) => {
const sshHostLoginUserMappingOrm = ormify(db, TableName.SshHostLoginUserMapping);
return sshHostLoginUserMappingOrm;
};
@@ -1,3 +1,5 @@
import { z } from "zod";
import { SshHostsSchema } from "@app/db/schemas";
export const sanitizedSshHost = SshHostsSchema.pick({
@@ -9,3 +11,10 @@ export const sanitizedSshHost = SshHostsSchema.pick({
userSshCaId: true,
hostSshCaId: true
});
export const loginMappingSchema = z.object({
loginUser: z.string(),
allowedPrincipals: z.object({
usernames: z.array(z.string())
})
});
@@ -9,8 +9,10 @@ import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/
import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
import { TSshHostLoginMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-mapping-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 { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -34,8 +36,8 @@ import {
TUpdateSshHostDTO
} from "./ssh-host-types";
type TSshCertificateAuthorityServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "findById">;
type TSshHostServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "findById" | "find">;
projectDAL: Pick<TProjectDALFactory, "find">;
projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "findOne">;
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findById">;
@@ -53,11 +55,9 @@ type TSshCertificateAuthorityServiceFactoryDep = {
| "findSshHostByIdWithLoginMappings"
| "findSshHostsWithPrincipalsAcrossProjects"
>;
sshHostLoginMappingDAL: Pick<
TSshHostLoginMappingDALFactory,
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "insertMany" | "delete"
>;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
sshHostLoginUserDAL: TSshHostLoginUserDALFactory;
sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
@@ -72,26 +72,26 @@ export const sshHostServiceFactory = ({
sshCertificateDAL,
sshCertificateBodyDAL,
sshHostDAL,
sshHostLoginMappingDAL,
sshHostLoginUserMappingDAL,
sshHostLoginUserDAL,
permissionService,
kmsService
}: TSshCertificateAuthorityServiceFactoryDep) => {
}: TSshHostServiceFactoryDep) => {
/**
* Return list of all SSH hosts that a user can issue user SSH certificates for
* (i.e. is able to access / connect to) across all SSH projects in the organization
*/
const listSshHosts = async ({ actorId, actorAuthMethod, actor, actorOrgId }: TListSshHostsDTO) => {
if (actor !== ActorType.USER) {
// (dangtony98): only support user for now
throw new BadRequestError({ message: `Actor type ${actor} not supported` });
}
const sshProjects = await projectDAL.find({
orgId: actorOrgId,
type: ProjectType.SSH
});
const principals = await convertActorToPrincipals({
actor,
actorId,
userDAL
});
const allowedHosts = [];
for await (const project of sshProjects) {
@@ -105,7 +105,7 @@ export const sshHostServiceFactory = ({
actionProjectType: ActionProjectType.SSH
});
const projectHosts = await sshHostDAL.findSshHostsWithPrincipalsAcrossProjects([project.id], principals);
const projectHosts = await sshHostDAL.findSshHostsWithPrincipalsAcrossProjects([project.id], actorId); // TODO: consider fn rename
allowedHosts.push(...projectHosts);
} catch {
@@ -208,15 +208,51 @@ export const sshHostServiceFactory = ({
tx
);
await sshHostLoginMappingDAL.insertMany(
loginMappings.map(({ loginUser, allowedPrincipals }) => ({
await sshHostLoginUserDAL.insertMany(
loginMappings.map(({ loginUser }) => ({
sshHostId: host.id,
loginUser,
allowedPrincipals
loginUser
})),
tx
);
for await (const { loginUser, allowedPrincipals } of loginMappings) {
const sshHostLoginUser = await sshHostLoginUserDAL.create(
{
sshHostId: host.id,
loginUser
},
tx
);
const users = await userDAL.find(
{
$in: {
username: allowedPrincipals.usernames
}
},
{ tx }
);
for await (const user of users) {
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
);
}
const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx);
if (!newSshHostWithLoginMappings) {
throw new NotFoundError({ message: `SSH host with ID '${host.id}' not found` });
@@ -270,16 +306,58 @@ export const sshHostServiceFactory = ({
);
if (loginMappings) {
await sshHostLoginMappingDAL.delete({ sshHostId }, tx);
await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx);
if (loginMappings.length) {
await sshHostLoginMappingDAL.insertMany(
loginMappings.map(({ loginUser, allowedPrincipals }) => ({
sshHostId: host.id,
loginUser,
allowedPrincipals
})),
tx
);
for await (const { loginUser, allowedPrincipals } of loginMappings) {
const sshHostLoginUser = await sshHostLoginUserDAL.create(
{
sshHostId: host.id,
loginUser
},
tx
);
if (allowedPrincipals.usernames.length === 0) {
continue; // or maybe insert no mappings and just skip validation
}
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
);
}
}
}
@@ -314,10 +392,7 @@ export const sshHostServiceFactory = ({
})
);
await sshHostDAL.transaction(async (tx) => {
await sshHostLoginMappingDAL.delete({ sshHostId }, tx);
await sshHostDAL.deleteById(sshHostId, tx);
});
await sshHostDAL.deleteById(sshHostId);
return host;
};
@@ -379,7 +454,9 @@ export const sshHostServiceFactory = ({
});
const mapping = host.loginMappings.find(
(m) => m.loginUser === loginUser && m.allowedPrincipals.some((allowed) => internalPrincipals.includes(allowed))
(m) =>
m.loginUser === loginUser &&
m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed))
);
if (!mapping) {
@@ -8,7 +8,9 @@ export type TCreateSshHostDTO = {
hostCertTtl: string;
loginMappings: {
loginUser: string;
allowedPrincipals: string[];
allowedPrincipals: {
usernames: string[];
};
}[];
userSshCaId?: string;
hostSshCaId?: string;
@@ -21,7 +23,9 @@ export type TUpdateSshHostDTO = {
hostCertTtl?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: string[];
allowedPrincipals: {
usernames: string[];
};
}[];
} & Omit<TProjectPermission, "projectId">;
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSshHostLoginUserDALFactory = ReturnType<typeof sshHostLoginUserDALFactory>;
export const sshHostLoginUserDALFactory = (db: TDbClient) => {
const sshHostLoginUserOrm = ormify(db, TableName.SshHostLoginUser);
return sshHostLoginUserOrm;
};