Merge pull request #3535 from Infisical/feat/addGroupsToSshHosts

feat(ssh-hosts): Add groups to ssh hosts allowed principals
This commit is contained in:
carlosmonastyrski
2025-05-09 22:52:35 -03:00
committed by GitHub
26 changed files with 639 additions and 208 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,23 @@ export const groupDALFactory = (db: TDbClient) => {
} }
}; };
const findGroupsByProjectId = async (projectId: string, tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.Groups)
.join(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"),
@@ -630,6 +630,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,
@@ -639,6 +667,7 @@ export const permissionServiceFactory = ({
getOrgPermissionByRole, getOrgPermissionByRole,
getProjectPermissionByRole, getProjectPermissionByRole,
buildOrgPermission, buildOrgPermission,
buildProjectPermissionRules buildProjectPermissionRules,
checkGroupProjectPermission
}; };
}; };
@@ -28,6 +28,7 @@ export const sshHostGroupDALFactory = (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.SshHostGroup}.projectId`, projectId) .where(`${TableName.SshHostGroup}.projectId`, projectId)
.select( .select(
db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"),
@@ -35,7 +36,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => {
db.ref("name").withSchema(TableName.SshHostGroup), db.ref("name").withSchema(TableName.SshHostGroup),
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")
) )
.orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc"); .orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc");
@@ -69,7 +71,8 @@ export const sshHostGroupDALFactory = (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)
} }
})); }));
return { return {
@@ -99,6 +102,7 @@ export const sshHostGroupDALFactory = (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.SshHostGroup}.id`, sshHostGroupId) .where(`${TableName.SshHostGroup}.id`, sshHostGroupId)
.select( .select(
db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"),
@@ -106,7 +110,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => {
db.ref("name").withSchema(TableName.SshHostGroup), db.ref("name").withSchema(TableName.SshHostGroup),
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")
); );
if (rows.length === 0) return null; if (rows.length === 0) return null;
@@ -121,7 +126,8 @@ export const sshHostGroupDALFactory = (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)
} }
})); }));
@@ -12,6 +12,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGroupDALFactory } from "../group/group-dal";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; import { createSshLoginMappings } from "../ssh-host/ssh-host-fns";
import { import {
@@ -43,8 +44,12 @@ type TSshHostGroupServiceFactoryDep = {
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">; sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">; sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">; userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">; permissionService: Pick<
TPermissionServiceFactory,
"getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
groupDAL: Pick<TGroupDALFactory, "findGroupsByProjectId">;
}; };
export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>; export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>;
@@ -58,7 +63,8 @@ export const sshHostGroupServiceFactory = ({
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
userDAL, userDAL,
permissionService, permissionService,
licenseService licenseService,
groupDAL
}: TSshHostGroupServiceFactoryDep) => { }: TSshHostGroupServiceFactoryDep) => {
const createSshHostGroup = async ({ const createSshHostGroup = async ({
projectId, projectId,
@@ -127,6 +133,7 @@ export const sshHostGroupServiceFactory = ({
loginMappings, loginMappings,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
groupDAL,
userDAL, userDAL,
permissionService, permissionService,
projectId, projectId,
@@ -194,6 +201,7 @@ export const sshHostGroupServiceFactory = ({
loginMappings, loginMappings,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
groupDAL,
userDAL, userDAL,
permissionService, permissionService,
projectId: sshHostGroup.projectId, projectId: sshHostGroup.projectId,
@@ -9,12 +9,7 @@ export type TCreateSshHostGroupDTO = {
export type TUpdateSshHostGroupDTO = { export type TUpdateSshHostGroupDTO = {
sshHostGroupId: string; sshHostGroupId: string;
name?: string; name?: string;
loginMappings?: { loginMappings?: TLoginMapping[];
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetSshHostGroupDTO = { export type TGetSshHostGroupDTO = {
@@ -31,8 +31,18 @@ export const sshHostDALFactory = (db: TDbClient) => {
`${TableName.SshHostLoginUser}.id`, `${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
) )
.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),
@@ -58,8 +68,17 @@ export const sshHostDALFactory = (db: TDbClient) => {
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
) )
.join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`)
.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),
@@ -133,6 +152,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"),
@@ -144,6 +164,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)
) )
@@ -163,10 +184,12 @@ 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`)
.select( .select(
db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership), db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership),
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("slug").withSchema(TableName.Groups).as("groupSlug")
) )
.whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds); .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds);
@@ -185,7 +208,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ const directMappings = 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)
}, },
source: LoginMappingSource.HOST source: LoginMappingSource.HOST
})); }));
@@ -197,7 +221,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
const groupMappings = Object.entries(inheritedGrouped).map(([loginUser, entries]) => ({ const groupMappings = Object.entries(inheritedGrouped).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)
}, },
source: LoginMappingSource.HOST_GROUP source: LoginMappingSource.HOST_GROUP
})); }));
@@ -229,6 +254,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"),
@@ -241,7 +267,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;
@@ -257,7 +284,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({ const directMappings = Object.entries(directGrouped).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)
}, },
source: LoginMappingSource.HOST source: LoginMappingSource.HOST
})); }));
@@ -275,10 +303,12 @@ 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.SshHostGroupMembership}.sshHostId`, sshHostId) .where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId)
.select( .select(
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("slug").withSchema(TableName.Groups).as("groupSlug")
); );
const groupGrouped = groupBy( const groupGrouped = groupBy(
@@ -289,7 +319,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
const groupMappings = Object.entries(groupGrouped).map(([loginUser, entries]) => ({ const groupMappings = Object.entries(groupGrouped).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)
}, },
source: LoginMappingSource.HOST_GROUP source: LoginMappingSource.HOST_GROUP
})); }));
@@ -3,6 +3,7 @@ import { Knex } from "knex";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "../permission/project-permission";
import { TCreateSshLoginMappingsDTO } from "./ssh-host-types"; import { TCreateSshLoginMappingsDTO } from "./ssh-host-types";
/** /**
@@ -15,6 +16,7 @@ export const createSshLoginMappings = async ({
loginMappings, loginMappings,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
groupDAL,
userDAL, userDAL,
permissionService, permissionService,
projectId, projectId,
@@ -35,7 +37,7 @@ export const createSshLoginMappings = async ({
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: {
@@ -74,6 +76,41 @@ export const createSshLoginMappings = async ({
tx 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
);
}
} }
}; };
@@ -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,
@@ -39,12 +41,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"
@@ -58,7 +62,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">;
}; };
@@ -66,6 +73,8 @@ export type TSshHostServiceFactory = ReturnType<typeof sshHostServiceFactory>;
export const sshHostServiceFactory = ({ export const sshHostServiceFactory = ({
userDAL, userDAL,
userGroupMembershipDAL,
groupDAL,
projectDAL, projectDAL,
projectSshConfigDAL, projectSshConfigDAL,
sshCertificateAuthorityDAL, sshCertificateAuthorityDAL,
@@ -208,6 +217,7 @@ export const sshHostServiceFactory = ({
loginMappings, loginMappings,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
groupDAL,
userDAL, userDAL,
permissionService, permissionService,
projectId, projectId,
@@ -278,6 +288,7 @@ export const sshHostServiceFactory = ({
loginMappings, loginMappings,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
groupDAL,
userDAL, userDAL,
permissionService, permissionService,
projectId: host.projectId, projectId: host.projectId,
@@ -387,10 +398,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) {
@@ -7,12 +7,15 @@ import { TProjectPermission } from "@app/lib/types";
import { ActorAuthMethod } from "@app/services/auth/auth-type"; import { ActorAuthMethod } from "@app/services/auth/auth-type";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGroupDALFactory } from "../group/group-dal";
export type TListSshHostsDTO = Omit<TProjectPermission, "projectId">; export type TListSshHostsDTO = Omit<TProjectPermission, "projectId">;
export type TLoginMapping = { export type TLoginMapping = {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames?: string[];
groups?: string[];
}; };
}; };
@@ -63,7 +66,8 @@ type BaseCreateSshLoginMappingsDTO = {
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction">; sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">; sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">; userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getUserProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getUserProjectPermission" | "checkGroupProjectPermission">;
groupDAL: Pick<TGroupDALFactory, "findGroupsByProjectId">;
projectId: string; projectId: string;
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
actorOrgId: string; actorOrgId: string;
+2 -2
View File
@@ -1478,7 +1478,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:
@@ -1493,7 +1493,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."
+4 -1
View File
@@ -870,6 +870,8 @@ export const registerRoutes = async (
const sshHostService = sshHostServiceFactory({ const sshHostService = sshHostServiceFactory({
userDAL, userDAL,
groupDAL,
userGroupMembershipDAL,
projectDAL, projectDAL,
projectSshConfigDAL, projectSshConfigDAL,
sshCertificateAuthorityDAL, sshCertificateAuthorityDAL,
@@ -892,7 +894,8 @@ export const registerRoutes = async (
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
userDAL, userDAL,
permissionService, permissionService,
licenseService licenseService,
groupDAL
}); });
const certificateAuthorityService = certificateAuthorityServiceFactory({ const certificateAuthorityService = certificateAuthorityServiceFactory({
+2 -1
View File
@@ -6,7 +6,8 @@ export enum LoginMappingSource {
export type TLoginMapping = { export type TLoginMapping = {
loginUser: string; loginUser: string;
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames?: string[];
groups?: string[];
}; };
source: LoginMappingSource; source: LoginMappingSource;
}; };
@@ -16,8 +16,11 @@ export const workspaceKeys = {
type ? ["workspaces", { type }] : (["workspaces"] as const), type ? ["workspaces", { type }] : (["workspaces"] as const),
getWorkspaceAuditLogs: (workspaceId: string) => getWorkspaceAuditLogs: (workspaceId: string) =>
[{ workspaceId }, "workspace-audit-logs"] as const, [{ workspaceId }, "workspace-audit-logs"] as const,
getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) => getWorkspaceUsers: (
[{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const, workspaceId: string,
includeGroupMembers: boolean = false,
roles: string[] = []
) => [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const,
getWorkspaceUserDetails: (workspaceId: string, membershipId: string) => getWorkspaceUserDetails: (workspaceId: string, membershipId: string) =>
[{ workspaceId, membershipId }, "workspace-user-details"] as const, [{ workspaceId, membershipId }, "workspace-user-details"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) => getWorkspaceIdentityMemberships: (workspaceId: string) =>
@@ -1,33 +1,37 @@
import { faHome } from "@fortawesome/free-solid-svg-icons"; import { faHome } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router"; import {
import { zodValidator } from "@tanstack/zod-adapter"; createFileRoute,
import { z } from "zod"; linkOptions,
stripSearchParams,
} from '@tanstack/react-router'
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
import { SettingsPage } from "./SettingsPage"; import { SettingsPage } from './SettingsPage'
const SettingsPageQueryParams = z.object({ const SettingsPageQueryParams = z.object({
selectedTab: z.string().catch("") selectedTab: z.string().catch(''),
}); })
export const Route = createFileRoute( export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/settings/" '/_authenticate/_inject-org-details/_org-layout/organization/settings/',
)({ )({
component: SettingsPage, component: SettingsPage,
validateSearch: zodValidator(SettingsPageQueryParams), validateSearch: zodValidator(SettingsPageQueryParams),
search: { search: {
middlewares: [stripSearchParams({ selectedTab: "" })] middlewares: [stripSearchParams({ selectedTab: '' })],
}, },
context: () => ({ context: () => ({
breadcrumbs: [ breadcrumbs: [
{ {
label: "Home", label: 'Home',
icon: () => <FontAwesomeIcon icon={faHome} />, icon: () => <FontAwesomeIcon icon={faHome} />,
link: linkOptions({ to: "/" }) link: linkOptions({ to: '/' }),
}, },
{ {
label: "Settings" label: 'Settings',
} },
] ],
}) }),
}); })
@@ -125,7 +125,7 @@ export const SshHostPermissionConditions = ({ position = 0, isDisabled }: Props)
errorText={error?.message} errorText={error?.message}
className="mb-0 flex-grow" className="mb-0 flex-grow"
> >
<Input {...field} /> <Input {...field} onChange={(e) => field.onChange(e.target.value.trim())} />
</FormControl> </FormControl>
)} )}
/> />
@@ -22,6 +22,7 @@ import {
useCreateSshHostGroup, useCreateSshHostGroup,
useGetSshHostGroupById, useGetSshHostGroupById,
useGetWorkspaceUsers, useGetWorkspaceUsers,
useListWorkspaceGroups,
useListWorkspaceSshHostGroups, useListWorkspaceSshHostGroups,
useUpdateSshHostGroup useUpdateSshHostGroup
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -38,7 +39,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([]) allowedPrincipals: z
.array(
z.object({
type: z.enum(["user", "group"]),
value: z.string().trim().min(1)
})
)
.default([])
}) })
.array() .array()
.default([]) .default([])
@@ -49,9 +57,10 @@ export type FormData = z.infer<typeof schema>;
export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || ""; const projectId = currentWorkspace.id;
const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id); const { data: sshHostGroups } = useListWorkspaceSshHostGroups(projectId);
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: sshHostGroup } = useGetSshHostGroupById( const { data: sshHostGroup } = useGetSshHostGroupById(
@@ -87,7 +96,16 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
name: sshHostGroup.name, name: sshHostGroup.name,
loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser, loginUser,
allowedPrincipals: allowedPrincipals.usernames allowedPrincipals: [
...(allowedPrincipals.usernames || []).map((username) => ({
type: "user" as const,
value: username
})),
...(allowedPrincipals.groups || []).map((group) => ({
type: "group" as const,
value: group
}))
]
})) }))
}); });
@@ -118,27 +136,35 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
return; return;
} }
const transformedLoginMappings = loginMappings.map(({ loginUser, allowedPrincipals }) => {
const usernames = allowedPrincipals
.filter((p) => p.type === "user" && p.value)
.map((p) => p.value);
const groupNames = allowedPrincipals
.filter((p) => p.type === "group" && p.value)
.map((p) => p.value);
return {
loginUser,
allowedPrincipals: {
usernames,
groups: groupNames
}
};
});
if (sshHostGroup) { if (sshHostGroup) {
await updateMutateAsync({ await updateMutateAsync({
sshHostGroupId: sshHostGroup.id, sshHostGroupId: sshHostGroup.id,
name, name,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: transformedLoginMappings
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
}); });
} else { } else {
await createMutateAsync({ await createMutateAsync({
projectId, projectId,
name, name,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: transformedLoginMappings
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
}); });
} }
@@ -165,6 +191,15 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
})); }));
}; };
const isPrincipalDuplicate = (
mappingIndex: number,
principalType: string,
principalValue: string
) => {
const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || [];
return principals.some((p) => p.type === principalType && p.value === principalValue);
};
return ( return (
<Modal <Modal
isOpen={popUp?.sshHostGroup?.isOpen} isOpen={popUp?.sshHostGroup?.isOpen}
@@ -203,7 +238,10 @@ export const SshHostGroupModal = ({ 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: "",
allowedPrincipals: [{ type: "user", value: "" }]
});
setExpandedMappings((prev) => ({ setExpandedMappings((prev) => ({
...prev, ...prev,
[newIndex]: true [newIndex]: true
@@ -299,7 +337,10 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); setValue(`loginMappings.${i}.allowedPrincipals`, [
...current,
{ type: "user", value: "" }
]);
}} }}
> >
Add Principal Add Principal
@@ -310,40 +351,69 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
name={`loginMappings.${i}.allowedPrincipals`} name={`loginMappings.${i}.allowedPrincipals`}
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={`principal-${i + 1}-${principalIndex + 1}-${principal.type}`}
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 as "user" | "group",
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"
@@ -353,14 +423,14 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
const newPrincipals = value.filter( const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex (_, idx) => idx !== principalIndex
); );
onChange(newPrincipals); onChange(newPrincipals.length ? newPrincipals : []);
}} }}
> >
<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>
)} )}
@@ -1,4 +1,4 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -56,22 +56,38 @@ export const SshHostGroupsSection = () => {
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between"> <div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Host Groups</p> <p className="text-xl font-semibold text-mineshaft-100">Host Groups</p>
<ProjectPermissionCan <div className="flex justify-end">
I={ProjectPermissionActions.Create} <a
a={ProjectPermissionSub.SshHostGroups} target="_blank"
> rel="noopener noreferrer"
{(isAllowed) => ( href="https://infisical.com/docs/documentation/platform/ssh/host-groups"
<Button >
colorSchema="primary" <span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
type="button" Documentation{" "}
leftIcon={<FontAwesomeIcon icon={faPlus} />} <FontAwesomeIcon
onClick={() => handleAddSshHostGroupModal()} icon={faArrowUpRightFromSquare}
isDisabled={!isAllowed} className="mb-[0.06rem] ml-1 text-xs"
> />
Add Group </span>
</Button> </a>
)} <ProjectPermissionCan
</ProjectPermissionCan> I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddSshHostGroupModal()}
isDisabled={!isAllowed}
className="ml-4"
>
Add Group
</Button>
)}
</ProjectPermissionCan>
</div>
</div> </div>
<SshHostGroupsTable handlePopUpOpen={handlePopUpOpen} /> <SshHostGroupsTable handlePopUpOpen={handlePopUpOpen} />
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
@@ -1,10 +1,18 @@
import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; import {
faEllipsis,
faPencil,
faServer,
faTrash,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Badge,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
@@ -77,9 +85,40 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
group.loginMappings.map(({ loginUser, allowedPrincipals }) => ( group.loginMappings.map(({ loginUser, allowedPrincipals }) => (
<div key={`${group.id}-${loginUser}`} className="mb-2"> <div key={`${group.id}-${loginUser}`} className="mb-2">
<div className="text-mineshaft-200">{loginUser}</div> <div className="text-mineshaft-200">{loginUser}</div>
{allowedPrincipals.usernames.map((username) => ( {allowedPrincipals.usernames?.map((username) => (
<div key={`${group.id}-${loginUser}-${username}`} className="ml-4"> <div
└─ {username} key={`${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((allowedGroup) => (
<div
key={`${loginUser}-${allowedGroup}`}
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>{allowedGroup}</span>
<Badge variant="success">group</Badge>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -23,6 +23,7 @@ import {
useCreateSshHost, useCreateSshHost,
useGetSshHostById, useGetSshHostById,
useGetWorkspaceUsers, useGetWorkspaceUsers,
useListWorkspaceGroups,
useListWorkspaceSshHosts, useListWorkspaceSshHosts,
useUpdateSshHost useUpdateSshHost
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -49,7 +50,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([]), allowedPrincipals: z
.array(
z.object({
type: z.enum(["user", "group"]),
value: z.string().trim().min(1)
})
)
.default([]),
source: z.nativeEnum(LoginMappingSource) source: z.nativeEnum(LoginMappingSource)
}) })
.array() .array()
@@ -64,6 +72,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(
@@ -103,7 +112,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
userCertTtl: sshHost.userCertTtl, userCertTtl: sshHost.userCertTtl,
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({ loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({
loginUser, loginUser,
allowedPrincipals: allowedPrincipals.usernames, allowedPrincipals: [
...(allowedPrincipals.usernames || []).map((username) => ({
type: "user" as const,
value: username
})),
...(allowedPrincipals.groups || []).map((group) => ({
type: "group" as const,
value: group
}))
],
source source
})) }))
}); });
@@ -159,18 +177,31 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
} }
} }
const transformedLoginMappings = hostLoginMappings.map(({ loginUser, allowedPrincipals }) => {
const usernames = allowedPrincipals
.filter((p) => p.type === "user" && p.value)
.map((p) => p.value);
const groupNames = allowedPrincipals
.filter((p) => p.type === "group" && p.value)
.map((p) => p.value);
return {
loginUser,
allowedPrincipals: {
usernames,
groups: groupNames
}
};
});
if (sshHost) { if (sshHost) {
await updateMutateAsync({ await updateMutateAsync({
sshHostId: sshHost.id, sshHostId: sshHost.id,
hostname, hostname,
alias: trimmedAlias, alias: trimmedAlias,
userCertTtl, userCertTtl,
loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: transformedLoginMappings
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
}); });
} else { } else {
await createMutateAsync({ await createMutateAsync({
@@ -178,12 +209,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname, hostname,
alias: trimmedAlias, alias: trimmedAlias,
userCertTtl, userCertTtl,
loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: transformedLoginMappings
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
}); });
} }
@@ -210,6 +236,15 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
})); }));
}; };
const isPrincipalDuplicate = (
mappingIndex: number,
principalType: string,
principalValue: string
) => {
const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || [];
return principals.some((p) => p.type === principalType && p.value === principalValue);
};
return ( return (
<Modal <Modal
isOpen={popUp?.sshHost?.isOpen} isOpen={popUp?.sshHost?.isOpen}
@@ -275,7 +310,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
const newIndex = loginMappingsFormFields.fields.length; const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginMappingsFormFields.append({
loginUser: "", loginUser: "",
allowedPrincipals: [""], allowedPrincipals: [],
source: LoginMappingSource.HOST source: LoginMappingSource.HOST
}); });
setExpandedMappings((prev) => ({ setExpandedMappings((prev) => ({
@@ -397,7 +432,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
onClick={() => { onClick={() => {
const current = const current =
getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); setValue(`loginMappings.${i}.allowedPrincipals`, [
...current,
{ type: "user", value: "" }
]);
}} }}
> >
Add Principal Add Principal
@@ -409,50 +447,82 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
name={`loginMappings.${i}.allowedPrincipals`} name={`loginMappings.${i}.allowedPrincipals`}
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={`principal-${i + 1}-${principalIndex + 1}-${principal.type}`}
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 ( const newPrincipals = [...value];
loginMappingsFormFields.fields[i].source === newPrincipals[principalIndex] = {
LoginMappingSource.HOST_GROUP type: newType as "user" | "group",
) value: ""
return; };
onChange(newPrincipals);
if (value.includes(newValue)) { }}
createNotification({ isDisabled={
text: "This principal is already added", loginMappingsFormFields.fields[i].source ===
type: "error" LoginMappingSource.HOST_GROUP
}); }
return; >
} <SelectItem value="user">User</SelectItem>
const newPrincipals = [...value]; <SelectItem value="group">Group</SelectItem>
newPrincipals[principalIndex] = newValue; </Select>
onChange(newPrincipals); </div>
}} <div className="flex-1">
placeholder="Select a member" <Select
className="w-full" value={principal.value}
isDisabled={ onValueChange={(newValue) => {
if (
loginMappingsFormFields.fields[i].source === loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP LoginMappingSource.HOST_GROUP
)
return;
if (isPrincipalDuplicate(i, principal.type, newValue)) {
createNotification({
text: `This ${principal.type} is already added`,
type: "error"
});
return;
} }
> const newPrincipals = [...value];
{members.map((member) => ( newPrincipals[principalIndex] = {
<SelectItem type: principal.type,
key={member.user.id} value: newValue
value={member.user.username} };
> onChange(newPrincipals);
{member.user.username} }}
</SelectItem> placeholder={`Select a ${principal.type}`}
))} className="w-full"
</Select> isDisabled={
</div> loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
>
{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"
@@ -468,7 +538,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
const newPrincipals = value.filter( const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex (_, idx) => idx !== principalIndex
); );
onChange(newPrincipals); onChange([...newPrincipals]);
}} }}
isDisabled={ isDisabled={
loginMappingsFormFields.fields[i].source === loginMappingsFormFields.fields[i].source ===
@@ -478,8 +548,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>
)} )}
@@ -42,7 +42,7 @@ export const SshHostsSection = () => {
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between"> <div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Hosts</p> <p className="text-xl font-semibold text-mineshaft-100">Hosts</p>
<div className="flex w-full justify-end"> <div className="flex justify-end">
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -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,
@@ -101,43 +104,53 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
const hostLoginUserToPrincipals = hostMappings.reduce( const hostLoginUserToPrincipals = hostMappings.reduce(
(acc, { loginUser, allowedPrincipals }) => { (acc, { loginUser, allowedPrincipals }) => {
acc[loginUser] = new Set(allowedPrincipals.usernames); acc[loginUser] = {
users: new Set(allowedPrincipals.usernames),
groups: new Set(allowedPrincipals.groups)
};
return acc; return acc;
}, },
{} as Record<string, Set<string>> {} as Record<string, { users: Set<string>; groups: Set<string> }>
); );
const entriesFromHost = hostMappings.map( const entriesFromHost = hostMappings.map(
({ loginUser, allowedPrincipals }) => ({ ({ loginUser, allowedPrincipals }) => ({
loginUser, loginUser,
source: LoginMappingSource.HOST, source: LoginMappingSource.HOST,
usernames: allowedPrincipals.usernames users: allowedPrincipals.usernames,
groups: allowedPrincipals.groups
}) })
); );
const entriesFromGroup = groupMappings const entriesFromGroup = groupMappings
.map(({ loginUser, allowedPrincipals }) => { .map(({ loginUser, allowedPrincipals }) => {
const existing = hostLoginUserToPrincipals[loginUser] || new Set(); const existing = hostLoginUserToPrincipals[loginUser] || {};
const filteredUsernames = allowedPrincipals.usernames.filter( const filteredUsernames = allowedPrincipals.usernames?.filter(
(u) => !existing.has(u) (u) => !existing.users?.has(u)
); );
return filteredUsernames.length > 0 const filteredGroups = allowedPrincipals.groups?.filter(
(g) => !existing.groups?.has(g)
);
return ((filteredGroups?.length || filteredUsernames?.length) ?? 0) >
0
? { ? {
loginUser, loginUser,
source: LoginMappingSource.HOST_GROUP, source: LoginMappingSource.HOST_GROUP,
usernames: filteredUsernames users: filteredUsernames,
groups: filteredGroups
} }
: null; : null;
}) })
.filter(Boolean) as { .filter(Boolean) as {
loginUser: string; loginUser: string;
source: LoginMappingSource; source: LoginMappingSource;
usernames: string[]; users: string[];
groups: string[];
}[]; }[];
return [...entriesFromHost, ...entriesFromGroup] return [...entriesFromHost, ...entriesFromGroup]
.sort((a, b) => a.loginUser.localeCompare(b.loginUser)) .sort((a, b) => a.loginUser.localeCompare(b.loginUser))
.map(({ loginUser, usernames, source }) => ( .map(({ loginUser, users, groups, source }) => (
<div key={`${host.id}-${loginUser}-${source}`} className="mb-2"> <div key={`${host.id}-${loginUser}-${source}`} className="mb-2">
<div className="text-mineshaft-200"> <div className="text-mineshaft-200">
{loginUser} {loginUser}
@@ -147,12 +160,40 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
</span> </span>
)} )}
</div> </div>
{usernames.map((username) => ( {users?.map((username) => (
<div <div
key={`${host.id}-${loginUser}-${source}-${username}`} key={`${host.id}-${loginUser}-${source}-${username}`}
className="ml-4" className="flex items-center gap-2"
> >
└─ {username} <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>
))}
{groups?.map((group) => (
<div
key={`${host.id}-${loginUser}-${source}-${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>