Add groups to ssh hosts allowed principals

This commit is contained in:
carlosmonastyrski
2025-05-02 11:14:43 -03:00
parent 5bd7dd4d65
commit 36916704be
16 changed files with 381 additions and 82 deletions
@@ -0,0 +1,22 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.SshHostLoginUserMapping, "groupId"))) {
await knex.schema.alterTable(TableName.SshHostLoginUserMapping, (t) => {
t.uuid("groupId").nullable();
t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE");
t.unique(["sshHostLoginUserId", "groupId"]);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SshHostLoginUserMapping, "groupId")) {
await knex.schema.alterTable(TableName.SshHostLoginUserMapping, (t) => {
t.dropUnique(["sshHostLoginUserId", "groupId"]);
t.dropColumn("groupId");
});
}
}
@@ -12,7 +12,8 @@ export const SshHostLoginUserMappingsSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
sshHostLoginUserId: z.string().uuid(), sshHostLoginUserId: z.string().uuid(),
userId: z.string().uuid().nullable().optional() userId: z.string().uuid().nullable().optional(),
groupId: z.string().uuid().nullable().optional()
}); });
export type TSshHostLoginUserMappings = z.infer<typeof SshHostLoginUserMappingsSchema>; export type TSshHostLoginUserMappings = z.infer<typeof SshHostLoginUserMappingsSchema>;
@@ -157,10 +157,27 @@ export const groupDALFactory = (db: TDbClient) => {
} }
}; };
const findGroupsByProjectId = async (projectId: string, tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.Groups)
.leftJoin(
TableName.GroupProjectMembership,
`${TableName.Groups}.id`,
`${TableName.GroupProjectMembership}.groupId`
)
.where(`${TableName.GroupProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.Groups));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "Find groups by project id" });
}
};
return { return {
findGroups, findGroups,
findByOrgId, findByOrgId,
findAllGroupPossibleMembers, findAllGroupPossibleMembers,
findGroupsByProjectId,
...groupOrm ...groupOrm
}; };
}; };
@@ -176,7 +176,8 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
db.ref("name").withSchema(TableName.Groups).as("groupName"), db.ref("name").withSchema(TableName.Groups).as("groupName"),
db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"), db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"),
db.ref("firstName").withSchema(TableName.Users).as("firstName"), db.ref("firstName").withSchema(TableName.Users).as("firstName"),
db.ref("lastName").withSchema(TableName.Users).as("lastName") db.ref("lastName").withSchema(TableName.Users).as("lastName"),
db.ref("slug").withSchema(TableName.Groups).as("groupSlug")
); );
return docs; return docs;
@@ -132,7 +132,7 @@ export const permissionDALFactory = (db: TDbClient) => {
} }
}; };
const getProjectGroupPermissions = async (projectId: string) => { const getProjectGroupPermissions = async (projectId: string, filterGroupId?: string) => {
try { try {
const docs = await db const docs = await db
.replicaNode()(TableName.GroupProjectMembership) .replicaNode()(TableName.GroupProjectMembership)
@@ -148,6 +148,11 @@ export const permissionDALFactory = (db: TDbClient) => {
`groupCustomRoles.id` `groupCustomRoles.id`
) )
.where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId) .where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId)
.where((bd) => {
if (filterGroupId) {
void bd.where(`${TableName.GroupProjectMembership}.groupId`, "=", filterGroupId);
}
})
.select( .select(
db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"),
db.ref("id").withSchema(TableName.Groups).as("groupId"), db.ref("id").withSchema(TableName.Groups).as("groupId"),
@@ -625,6 +625,34 @@ export const permissionServiceFactory = ({
return { permission }; return { permission };
}; };
const checkGroupProjectPermission = async ({
groupId,
projectId,
checkPermissions
}: {
groupId: string;
projectId: string;
checkPermissions: ProjectPermissionSet;
}) => {
const rawGroupProjectPermissions = await permissionDAL.getProjectGroupPermissions(projectId, groupId);
const groupPermissions = rawGroupProjectPermissions.map((groupProjectPermission) => {
const rolePermissions =
groupProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || [];
const rules = buildProjectPermissionRules(rolePermissions);
const permission = createMongoAbility<ProjectPermissionSet>(rules, {
conditionsMatcher
});
return {
permission,
id: groupProjectPermission.groupId,
name: groupProjectPermission.username,
membershipId: groupProjectPermission.id
};
});
return groupPermissions.some((groupPermission) => groupPermission.permission.can(...checkPermissions));
};
return { return {
getUserOrgPermission, getUserOrgPermission,
getOrgPermission, getOrgPermission,
@@ -634,6 +662,7 @@ export const permissionServiceFactory = ({
getOrgPermissionByRole, getOrgPermissionByRole,
getProjectPermissionByRole, getProjectPermissionByRole,
buildOrgPermission, buildOrgPermission,
buildProjectPermissionRules buildProjectPermissionRules,
checkGroupProjectPermission
}; };
}; };
@@ -27,8 +27,17 @@ export const sshHostDALFactory = (db: TDbClient) => {
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
) )
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.UserGroupMembership}.groupId`,
`${TableName.SshHostLoginUserMapping}.groupId`
)
.whereIn(`${TableName.SshHost}.projectId`, projectIds) .whereIn(`${TableName.SshHost}.projectId`, projectIds)
.andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) .andWhere((bd) => {
void bd
.where(`${TableName.SshHostLoginUserMapping}.userId`, userId)
.orWhere(`${TableName.UserGroupMembership}.userId`, userId);
})
.select( .select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
db.ref("projectId").withSchema(TableName.SshHost), db.ref("projectId").withSchema(TableName.SshHost),
@@ -85,6 +94,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
) )
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`)
.where(`${TableName.SshHost}.projectId`, projectId) .where(`${TableName.SshHost}.projectId`, projectId)
.select( .select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
@@ -96,6 +106,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("loginUser").withSchema(TableName.SshHostLoginUser),
db.ref("username").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users),
db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userId").withSchema(TableName.SshHostLoginUserMapping),
db.ref("slug").withSchema(TableName.Groups).as("groupSlug"),
db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("userSshCaId").withSchema(TableName.SshHost),
db.ref("hostSshCaId").withSchema(TableName.SshHost) db.ref("hostSshCaId").withSchema(TableName.SshHost)
) )
@@ -113,7 +124,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser, loginUser,
allowedPrincipals: { allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean) usernames: unique(entries.map((e) => e.username)).filter(Boolean),
groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean)
} }
})); }));
@@ -144,6 +156,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
) )
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`)
.where(`${TableName.SshHost}.id`, sshHostId) .where(`${TableName.SshHost}.id`, sshHostId)
.select( .select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
@@ -156,7 +169,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
db.ref("username").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users),
db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userId").withSchema(TableName.SshHostLoginUserMapping),
db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("userSshCaId").withSchema(TableName.SshHost),
db.ref("hostSshCaId").withSchema(TableName.SshHost) db.ref("hostSshCaId").withSchema(TableName.SshHost),
db.ref("slug").withSchema(TableName.Groups).as("groupSlug")
); );
if (rows.length === 0) return null; if (rows.length === 0) return null;
@@ -171,7 +185,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser, loginUser,
allowedPrincipals: { allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean) usernames: unique(entries.map((e) => e.username)).filter(Boolean),
groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean)
} }
})); }));
@@ -15,7 +15,24 @@ export const sanitizedSshHost = SshHostsSchema.pick({
export const loginMappingSchema = z.object({ export const loginMappingSchema = z.object({
loginUser: z.string().trim(), loginUser: z.string().trim(),
allowedPrincipals: z.object({ allowedPrincipals: z
usernames: z.array(z.string().trim()).transform((usernames) => Array.from(new Set(usernames))) .object({
}) usernames: z
.array(z.string().trim())
.transform((usernames) => Array.from(new Set(usernames)))
.optional(),
groups: z
.array(z.string().trim())
.transform((groups) => Array.from(new Set(groups)))
.optional()
})
.refine(
(data) => {
return (data.usernames && data.usernames.length > 0) || (data.groups && data.groups.length > 0);
},
{
message: "At least one username or group must be provided",
path: ["allowedPrincipals"]
}
)
}); });
@@ -1,6 +1,7 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { ActionProjectType, ProjectType } from "@app/db/schemas";
import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
@@ -19,6 +20,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
import { import {
convertActorToPrincipals, convertActorToPrincipals,
createSshCert, createSshCert,
@@ -38,12 +40,14 @@ import {
type TSshHostServiceFactoryDep = { type TSshHostServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "findById" | "find">; userDAL: Pick<TUserDALFactory, "findById" | "find">;
groupDAL: Pick<TGroupDALFactory, "findGroupsByProjectId">;
projectDAL: Pick<TProjectDALFactory, "find">; projectDAL: Pick<TProjectDALFactory, "find">;
projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "findOne">; projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "findOne">;
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findOne">; sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findOne">;
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "findOne">; sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "findOne">;
sshCertificateDAL: Pick<TSshCertificateDALFactory, "create" | "transaction">; sshCertificateDAL: Pick<TSshCertificateDALFactory, "create" | "transaction">;
sshCertificateBodyDAL: Pick<TSshCertificateBodyDALFactory, "create">; sshCertificateBodyDAL: Pick<TSshCertificateBodyDALFactory, "create">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findGroupMembershipsByUserIdInOrg">;
sshHostDAL: Pick< sshHostDAL: Pick<
TSshHostDALFactory, TSshHostDALFactory,
| "transaction" | "transaction"
@@ -57,7 +61,10 @@ type TSshHostServiceFactoryDep = {
>; >;
sshHostLoginUserDAL: TSshHostLoginUserDALFactory; sshHostLoginUserDAL: TSshHostLoginUserDALFactory;
sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">; permissionService: Pick<
TPermissionServiceFactory,
"getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission"
>;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}; };
@@ -65,6 +72,8 @@ export type TSshHostServiceFactory = ReturnType<typeof sshHostServiceFactory>;
export const sshHostServiceFactory = ({ export const sshHostServiceFactory = ({
userDAL, userDAL,
userGroupMembershipDAL,
groupDAL,
projectDAL, projectDAL,
projectSshConfigDAL, projectSshConfigDAL,
sshCertificateAuthorityDAL, sshCertificateAuthorityDAL,
@@ -212,7 +221,7 @@ export const sshHostServiceFactory = ({
tx tx
); );
if (allowedPrincipals.usernames.length > 0) { if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) {
const users = await userDAL.find( const users = await userDAL.find(
{ {
$in: { $in: {
@@ -251,6 +260,42 @@ export const sshHostServiceFactory = ({
tx tx
); );
} }
if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) {
const groups = await groupDAL.findGroupsByProjectId(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,
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
);
}
} }
const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx);
@@ -319,7 +364,7 @@ export const sshHostServiceFactory = ({
tx tx
); );
if (allowedPrincipals.usernames.length > 0) { if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) {
const users = await userDAL.find( const users = await userDAL.find(
{ {
$in: { $in: {
@@ -357,6 +402,42 @@ export const sshHostServiceFactory = ({
tx 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
);
}
} }
} }
} }
@@ -460,10 +541,14 @@ export const sshHostServiceFactory = ({
userDAL userDAL
}); });
const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actorId, actorOrgId);
const userGroupSlugs = userGroups.map((g) => g.groupSlug);
const mapping = host.loginMappings.find( const mapping = host.loginMappings.find(
(m) => (m) =>
m.loginUser === loginUser && m.loginUser === loginUser &&
m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) (m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) ||
m.allowedPrincipals.groups.some((allowed) => userGroupSlugs.includes(allowed)))
); );
if (!mapping) { if (!mapping) {
@@ -10,7 +10,8 @@ export type TCreateSshHostDTO = {
loginMappings: { loginMappings: {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames?: string[];
groups?: string[];
}; };
}[]; }[];
userSshCaId?: string; userSshCaId?: string;
@@ -26,7 +27,8 @@ export type TUpdateSshHostDTO = {
loginMappings?: { loginMappings?: {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames?: string[];
groups?: string[];
}; };
}[]; }[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
+2 -2
View File
@@ -1395,7 +1395,7 @@ export const SSH_HOSTS = {
loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')",
allowedPrincipals: "A list of allowed principals that can log in as the login user.", allowedPrincipals: "A list of allowed principals that can log in as the login user.",
loginMappings: loginMappings:
"A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project.", "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users or groups slugs in the Infisical SSH project.",
userSshCaId: userSshCaId:
"The ID of the SSH CA to use for user certificates. If not specified, the default user SSH CA will be used if it exists.", "The ID of the SSH CA to use for user certificates. If not specified, the default user SSH CA will be used if it exists.",
hostSshCaId: hostSshCaId:
@@ -1410,7 +1410,7 @@ export const SSH_HOSTS = {
loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')",
allowedPrincipals: "A list of allowed principals that can log in as the login user.", allowedPrincipals: "A list of allowed principals that can log in as the login user.",
loginMappings: loginMappings:
"A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users or groups slugs in the Infisical SSH project."
}, },
DELETE: { DELETE: {
sshHostId: "The ID of the SSH host to delete." sshHostId: "The ID of the SSH host to delete."
+2
View File
@@ -836,6 +836,8 @@ export const registerRoutes = async (
const sshHostService = sshHostServiceFactory({ const sshHostService = sshHostServiceFactory({
userDAL, userDAL,
groupDAL,
userGroupMembershipDAL,
projectDAL, projectDAL,
projectSshConfigDAL, projectSshConfigDAL,
sshCertificateAuthorityDAL, sshCertificateAuthorityDAL,
@@ -7,6 +7,7 @@ import { ProjectType, SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecre
import { TKeyStoreFactory } from "@app/keystore/keystore"; import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { generateCacheKeyFromData } from "@app/lib/crypto/cache"; import { generateCacheKeyFromData } from "@app/lib/crypto/cache";
import { applyJitter } from "@app/lib/dates";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { import {
buildFindFilter, buildFindFilter,
@@ -22,7 +23,6 @@ import type {
TFindSecretsByFolderIdsFilter, TFindSecretsByFolderIdsFilter,
TGetSecretsDTO TGetSecretsDTO
} from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; } from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
import { applyJitter } from "@app/lib/dates";
export const SecretServiceCacheKeys = { export const SecretServiceCacheKeys = {
get productKey() { get productKey() {
+3
View File
@@ -9,6 +9,7 @@ export type TSshHost = {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames: string[];
groups: string[];
}; };
}[]; }[];
}; };
@@ -23,6 +24,7 @@ export type TCreateSshHostDTO = {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames: string[];
groups: string[];
}; };
}[]; }[];
}; };
@@ -37,6 +39,7 @@ export type TUpdateSshHostDTO = {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames: string[];
groups: string[];
}; };
}[]; }[];
}; };
@@ -23,6 +23,7 @@ import {
useCreateSshHost, useCreateSshHost,
useGetSshHostById, useGetSshHostById,
useGetWorkspaceUsers, useGetWorkspaceUsers,
useListWorkspaceGroups,
useListWorkspaceSshHosts, useListWorkspaceSshHosts,
useUpdateSshHost useUpdateSshHost
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -48,7 +49,14 @@ const schema = z
loginMappings: z loginMappings: z
.object({ .object({
loginUser: z.string().trim().min(1), loginUser: z.string().trim().min(1),
allowedPrincipals: z.array(z.string().trim()).default([]) principals: z
.array(
z.object({
type: z.enum(["user", "group"]),
value: z.string().trim()
})
)
.default([])
}) })
.array() .array()
.default([]) .default([])
@@ -62,6 +70,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
const projectId = currentWorkspace?.id || ""; const projectId = currentWorkspace?.id || "";
const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id); const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id);
const { data: members = [] } = useGetWorkspaceUsers(projectId); const { data: members = [] } = useGetWorkspaceUsers(projectId);
const { data: groups = [] } = useListWorkspaceGroups(projectId);
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({}); const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
const { data: sshHost } = useGetSshHostById( const { data: sshHost } = useGetSshHostById(
@@ -101,7 +110,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
userCertTtl: sshHost.userCertTtl, userCertTtl: sshHost.userCertTtl,
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser, loginUser,
allowedPrincipals: allowedPrincipals.usernames principals: [
...(allowedPrincipals.usernames || []).map((username) => ({
type: "user" as const,
value: username
})),
...(allowedPrincipals.groups || []).map((group) => ({
type: "group" as const,
value: group
}))
]
})) }))
}); });
@@ -151,18 +169,28 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
} }
const transformedLoginMappings = loginMappings.map(({ loginUser, principals }) => {
const usernames = principals.filter((p) => p.type === "user").map((p) => p.value);
const groupNames = principals.filter((p) => p.type === "group").map((p) => p.value);
return {
loginUser,
allowedPrincipals: {
usernames,
groups: groupNames
}
};
});
console.log(transformedLoginMappings);
if (sshHost) { if (sshHost) {
await updateMutateAsync({ await updateMutateAsync({
sshHostId: sshHost.id, sshHostId: sshHost.id,
hostname, hostname,
alias: trimmedAlias, alias: trimmedAlias,
userCertTtl, userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: transformedLoginMappings
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
}); });
} else { } else {
await createMutateAsync({ await createMutateAsync({
@@ -170,12 +198,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname, hostname,
alias: trimmedAlias, alias: trimmedAlias,
userCertTtl, userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: transformedLoginMappings
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
}); });
} }
@@ -202,6 +225,15 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
})); }));
}; };
const isPrincipalDuplicate = (
mappingIndex: number,
principalType: string,
principalValue: string
) => {
const principals = getValues(`loginMappings.${mappingIndex}.principals`) || [];
return principals.some((p) => p.type === principalType && p.value === principalValue);
};
return ( return (
<Modal <Modal
isOpen={popUp?.sshHost?.isOpen} isOpen={popUp?.sshHost?.isOpen}
@@ -265,7 +297,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
const newIndex = loginMappingsFormFields.fields.length; const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] }); loginMappingsFormFields.append({ loginUser: "", principals: [] });
setExpandedMappings((prev) => ({ setExpandedMappings((prev) => ({
...prev, ...prev,
[newIndex]: true [newIndex]: true
@@ -360,8 +392,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
size="xs" size="xs"
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; const current = getValues(`loginMappings.${i}.principals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); setValue(`loginMappings.${i}.principals`, [
...current,
{ type: "user", value: "" }
]);
}} }}
> >
Add Principal Add Principal
@@ -369,43 +404,72 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
</div> </div>
<Controller <Controller
control={control} control={control}
name={`loginMappings.${i}.allowedPrincipals`} name={`loginMappings.${i}.principals`}
render={({ field: { value = [], onChange }, fieldState: { error } }) => ( render={({ field: { value = [], onChange }, fieldState: { error } }) => (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
{(value.length === 0 ? [""] : value).map( {value.map((principal, principalIndex) => (
(principal: string, principalIndex: number) => ( <div
<div key={`${metadataFieldId}-principal-${principalIndex + 1}`}
key={`${metadataFieldId}-principal-${principal}`} className="flex items-center space-x-2"
className="flex items-center space-x-2" >
> <div className="mr-2">
<div className="flex-1"> <Select
<Select className="w-24"
value={principal} value={principal.type}
onValueChange={(newValue) => { onValueChange={(newType) => {
if (value.includes(newValue)) { const newPrincipals = [...value];
createNotification({ newPrincipals[principalIndex] = {
text: "This principal is already added", type: newType as "user" | "group",
type: "error" value: ""
}); };
return; onChange(newPrincipals);
} }}
const newPrincipals = [...value]; >
newPrincipals[principalIndex] = newValue; <SelectItem value="user">User</SelectItem>
onChange(newPrincipals); <SelectItem value="group">Group</SelectItem>
}} </Select>
placeholder="Select a member" </div>
className="w-full" <div className="flex-1">
> <Select
{members.map((member) => ( value={principal.value}
<SelectItem onValueChange={(newValue) => {
key={member.user.id} if (isPrincipalDuplicate(i, principal.type, newValue)) {
value={member.user.username} createNotification({
> text: `This ${principal.type} is already added`,
{member.user.username} type: "error"
</SelectItem> });
))} return;
</Select> }
</div> const newPrincipals = [...value];
newPrincipals[principalIndex] = {
type: principal.type,
value: newValue
};
onChange(newPrincipals);
}}
placeholder={`Select a ${principal.type}`}
className="w-full"
>
{principal.type === "user"
? members.map((member) => (
<SelectItem
key={member.user.id}
value={member.user.username}
>
{member.user.username}
</SelectItem>
))
: groups.map((group) => (
<SelectItem
key={group.group.slug}
value={group.group.slug}
>
{group.group.slug}
</SelectItem>
))}
</Select>
</div>
<div className="flex w-10 justify-center">
<IconButton <IconButton
size="sm" size="sm"
ariaLabel="delete principal" ariaLabel="delete principal"
@@ -421,8 +485,8 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
</div> </div>
) </div>
)} ))}
{error && <span className="text-sm text-red-500">{error.message}</span>} {error && <span className="text-sm text-red-500">{error.message}</span>}
</div> </div>
)} )}
@@ -3,7 +3,9 @@ import {
faEllipsis, faEllipsis,
faPencil, faPencil,
faServer, faServer,
faTrash faTrash,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver"; import FileSaver from "file-saver";
@@ -12,6 +14,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Badge,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
@@ -91,13 +94,46 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
<span className="italic text-mineshaft-400">None</span> <span className="italic text-mineshaft-400">None</span>
) : ( ) : (
host.loginMappings.map(({ loginUser, allowedPrincipals }) => ( host.loginMappings.map(({ loginUser, allowedPrincipals }) => (
<div key={`${host.id}-${loginUser}`} className="mb-2"> <div key={`${host.id}-${loginUser}`} className="mb-3">
<div className="text-mineshaft-200">{loginUser}</div> <div className="mb-1 text-mineshaft-200">{loginUser}</div>
{allowedPrincipals.usernames.map((username) => ( <div className="ml-4 flex flex-col gap-1">
<div key={`${host.id}-${loginUser}-${username}`} className="ml-4"> {allowedPrincipals.usernames.map((username) => (
└─ {username} <div
</div> key={`${host.id}-${loginUser}-${username}`}
))} className="flex items-center gap-2"
>
<div className="flex items-center">
<span className="text-gray-400">└─</span>
</div>
<div className="flex items-center gap-1.5">
<FontAwesomeIcon
icon={faUser}
className="text-xs text-yellow/80"
/>
<span>{username}</span>
<Badge variant="primary">user</Badge>
</div>
</div>
))}
{allowedPrincipals.groups.map((group) => (
<div
key={`${host.id}-${loginUser}-${group}`}
className="flex items-center gap-2"
>
<div className="flex items-center">
<span className="text-gray-400">└─</span>
</div>
<div className="flex items-center gap-1.5">
<FontAwesomeIcon
icon={faUsers}
className="text-xs text-green/80"
/>
<span>{group}</span>
<Badge variant="success">group</Badge>
</div>
</div>
))}
</div>
</div> </div>
)) ))
)} )}