mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-03 10:26:00 +00:00
Add groups to ssh hosts allowed principals
This commit is contained in:
+22
@@ -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(),
|
||||
updatedAt: z.date(),
|
||||
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>;
|
||||
|
||||
@@ -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 {
|
||||
findGroups,
|
||||
findByOrgId,
|
||||
findAllGroupPossibleMembers,
|
||||
findGroupsByProjectId,
|
||||
...groupOrm
|
||||
};
|
||||
};
|
||||
|
||||
@@ -176,7 +176,8 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
|
||||
db.ref("name").withSchema(TableName.Groups).as("groupName"),
|
||||
db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"),
|
||||
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;
|
||||
|
||||
@@ -132,7 +132,7 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectGroupPermissions = async (projectId: string) => {
|
||||
const getProjectGroupPermissions = async (projectId: string, filterGroupId?: string) => {
|
||||
try {
|
||||
const docs = await db
|
||||
.replicaNode()(TableName.GroupProjectMembership)
|
||||
@@ -148,6 +148,11 @@ export const permissionDALFactory = (db: TDbClient) => {
|
||||
`groupCustomRoles.id`
|
||||
)
|
||||
.where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId)
|
||||
.where((bd) => {
|
||||
if (filterGroupId) {
|
||||
void bd.where(`${TableName.GroupProjectMembership}.groupId`, "=", filterGroupId);
|
||||
}
|
||||
})
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"),
|
||||
db.ref("id").withSchema(TableName.Groups).as("groupId"),
|
||||
|
||||
@@ -625,6 +625,34 @@ export const permissionServiceFactory = ({
|
||||
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 {
|
||||
getUserOrgPermission,
|
||||
getOrgPermission,
|
||||
@@ -634,6 +662,7 @@ export const permissionServiceFactory = ({
|
||||
getOrgPermissionByRole,
|
||||
getProjectPermissionByRole,
|
||||
buildOrgPermission,
|
||||
buildProjectPermissionRules
|
||||
buildProjectPermissionRules,
|
||||
checkGroupProjectPermission
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,8 +27,17 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
`${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)
|
||||
.andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId)
|
||||
.andWhere((bd) => {
|
||||
void bd
|
||||
.where(`${TableName.SshHostLoginUserMapping}.userId`, userId)
|
||||
.orWhere(`${TableName.UserGroupMembership}.userId`, userId);
|
||||
})
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
|
||||
db.ref("projectId").withSchema(TableName.SshHost),
|
||||
@@ -85,6 +94,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
|
||||
)
|
||||
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`)
|
||||
.where(`${TableName.SshHost}.projectId`, projectId)
|
||||
.select(
|
||||
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("username").withSchema(TableName.Users),
|
||||
db.ref("userId").withSchema(TableName.SshHostLoginUserMapping),
|
||||
db.ref("slug").withSchema(TableName.Groups).as("groupSlug"),
|
||||
db.ref("userSshCaId").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]) => ({
|
||||
loginUser,
|
||||
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`
|
||||
)
|
||||
.leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`)
|
||||
.where(`${TableName.SshHost}.id`, sshHostId)
|
||||
.select(
|
||||
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("userId").withSchema(TableName.SshHostLoginUserMapping),
|
||||
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;
|
||||
@@ -171,7 +185,8 @@ export const sshHostDALFactory = (db: TDbClient) => {
|
||||
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
|
||||
loginUser,
|
||||
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({
|
||||
loginUser: z.string().trim(),
|
||||
allowedPrincipals: z.object({
|
||||
usernames: z.array(z.string().trim()).transform((usernames) => Array.from(new Set(usernames)))
|
||||
})
|
||||
allowedPrincipals: z
|
||||
.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 { 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 { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
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 { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
|
||||
import {
|
||||
convertActorToPrincipals,
|
||||
createSshCert,
|
||||
@@ -38,12 +40,14 @@ import {
|
||||
|
||||
type TSshHostServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "findById" | "find">;
|
||||
groupDAL: Pick<TGroupDALFactory, "findGroupsByProjectId">;
|
||||
projectDAL: Pick<TProjectDALFactory, "find">;
|
||||
projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "findOne">;
|
||||
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "findOne">;
|
||||
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "findOne">;
|
||||
sshCertificateDAL: Pick<TSshCertificateDALFactory, "create" | "transaction">;
|
||||
sshCertificateBodyDAL: Pick<TSshCertificateBodyDALFactory, "create">;
|
||||
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findGroupMembershipsByUserIdInOrg">;
|
||||
sshHostDAL: Pick<
|
||||
TSshHostDALFactory,
|
||||
| "transaction"
|
||||
@@ -57,7 +61,10 @@ type TSshHostServiceFactoryDep = {
|
||||
>;
|
||||
sshHostLoginUserDAL: TSshHostLoginUserDALFactory;
|
||||
sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
|
||||
permissionService: Pick<
|
||||
TPermissionServiceFactory,
|
||||
"getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission"
|
||||
>;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
@@ -65,6 +72,8 @@ export type TSshHostServiceFactory = ReturnType<typeof sshHostServiceFactory>;
|
||||
|
||||
export const sshHostServiceFactory = ({
|
||||
userDAL,
|
||||
userGroupMembershipDAL,
|
||||
groupDAL,
|
||||
projectDAL,
|
||||
projectSshConfigDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
@@ -212,7 +221,7 @@ export const sshHostServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
|
||||
if (allowedPrincipals.usernames.length > 0) {
|
||||
if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) {
|
||||
const users = await userDAL.find(
|
||||
{
|
||||
$in: {
|
||||
@@ -251,6 +260,42 @@ export const sshHostServiceFactory = ({
|
||||
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);
|
||||
@@ -319,7 +364,7 @@ export const sshHostServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
|
||||
if (allowedPrincipals.usernames.length > 0) {
|
||||
if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) {
|
||||
const users = await userDAL.find(
|
||||
{
|
||||
$in: {
|
||||
@@ -357,6 +402,42 @@ export const sshHostServiceFactory = ({
|
||||
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
|
||||
});
|
||||
|
||||
const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actorId, actorOrgId);
|
||||
const userGroupSlugs = userGroups.map((g) => g.groupSlug);
|
||||
|
||||
const mapping = host.loginMappings.find(
|
||||
(m) =>
|
||||
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) {
|
||||
|
||||
@@ -10,7 +10,8 @@ export type TCreateSshHostDTO = {
|
||||
loginMappings: {
|
||||
loginUser: string;
|
||||
allowedPrincipals: {
|
||||
usernames: string[];
|
||||
usernames?: string[];
|
||||
groups?: string[];
|
||||
};
|
||||
}[];
|
||||
userSshCaId?: string;
|
||||
@@ -26,7 +27,8 @@ export type TUpdateSshHostDTO = {
|
||||
loginMappings?: {
|
||||
loginUser: string;
|
||||
allowedPrincipals: {
|
||||
usernames: string[];
|
||||
usernames?: string[];
|
||||
groups?: string[];
|
||||
};
|
||||
}[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
@@ -1395,7 +1395,7 @@ export const SSH_HOSTS = {
|
||||
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.",
|
||||
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:
|
||||
"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:
|
||||
@@ -1410,7 +1410,7 @@ export const SSH_HOSTS = {
|
||||
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.",
|
||||
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: {
|
||||
sshHostId: "The ID of the SSH host to delete."
|
||||
|
||||
@@ -836,6 +836,8 @@ export const registerRoutes = async (
|
||||
|
||||
const sshHostService = sshHostServiceFactory({
|
||||
userDAL,
|
||||
groupDAL,
|
||||
userGroupMembershipDAL,
|
||||
projectDAL,
|
||||
projectSshConfigDAL,
|
||||
sshCertificateAuthorityDAL,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProjectType, SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecre
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { generateCacheKeyFromData } from "@app/lib/crypto/cache";
|
||||
import { applyJitter } from "@app/lib/dates";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import {
|
||||
buildFindFilter,
|
||||
@@ -22,7 +23,6 @@ import type {
|
||||
TFindSecretsByFolderIdsFilter,
|
||||
TGetSecretsDTO
|
||||
} from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
|
||||
import { applyJitter } from "@app/lib/dates";
|
||||
|
||||
export const SecretServiceCacheKeys = {
|
||||
get productKey() {
|
||||
|
||||
@@ -9,6 +9,7 @@ export type TSshHost = {
|
||||
loginUser: string;
|
||||
allowedPrincipals: {
|
||||
usernames: string[];
|
||||
groups: string[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
@@ -23,6 +24,7 @@ export type TCreateSshHostDTO = {
|
||||
loginUser: string;
|
||||
allowedPrincipals: {
|
||||
usernames: string[];
|
||||
groups: string[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
@@ -37,6 +39,7 @@ export type TUpdateSshHostDTO = {
|
||||
loginUser: string;
|
||||
allowedPrincipals: {
|
||||
usernames: string[];
|
||||
groups: string[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useCreateSshHost,
|
||||
useGetSshHostById,
|
||||
useGetWorkspaceUsers,
|
||||
useListWorkspaceGroups,
|
||||
useListWorkspaceSshHosts,
|
||||
useUpdateSshHost
|
||||
} from "@app/hooks/api";
|
||||
@@ -48,7 +49,14 @@ const schema = z
|
||||
loginMappings: z
|
||||
.object({
|
||||
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()
|
||||
.default([])
|
||||
@@ -62,6 +70,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id);
|
||||
const { data: members = [] } = useGetWorkspaceUsers(projectId);
|
||||
const { data: groups = [] } = useListWorkspaceGroups(projectId);
|
||||
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
|
||||
|
||||
const { data: sshHost } = useGetSshHostById(
|
||||
@@ -101,7 +110,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
userCertTtl: sshHost.userCertTtl,
|
||||
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
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) {
|
||||
await updateMutateAsync({
|
||||
sshHostId: sshHost.id,
|
||||
hostname,
|
||||
alias: trimmedAlias,
|
||||
userCertTtl,
|
||||
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames: allowedPrincipals
|
||||
}
|
||||
}))
|
||||
loginMappings: transformedLoginMappings
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
@@ -170,12 +198,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
hostname,
|
||||
alias: trimmedAlias,
|
||||
userCertTtl,
|
||||
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
|
||||
loginUser,
|
||||
allowedPrincipals: {
|
||||
usernames: allowedPrincipals
|
||||
}
|
||||
}))
|
||||
loginMappings: transformedLoginMappings
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
isOpen={popUp?.sshHost?.isOpen}
|
||||
@@ -265,7 +297,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const newIndex = loginMappingsFormFields.fields.length;
|
||||
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
|
||||
loginMappingsFormFields.append({ loginUser: "", principals: [] });
|
||||
setExpandedMappings((prev) => ({
|
||||
...prev,
|
||||
[newIndex]: true
|
||||
@@ -360,8 +392,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
|
||||
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
|
||||
const current = getValues(`loginMappings.${i}.principals`) ?? [];
|
||||
setValue(`loginMappings.${i}.principals`, [
|
||||
...current,
|
||||
{ type: "user", value: "" }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Principal
|
||||
@@ -369,43 +404,72 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`loginMappings.${i}.allowedPrincipals`}
|
||||
name={`loginMappings.${i}.principals`}
|
||||
render={({ field: { value = [], onChange }, fieldState: { error } }) => (
|
||||
<div className="flex flex-col space-y-2">
|
||||
{(value.length === 0 ? [""] : value).map(
|
||||
(principal: string, principalIndex: number) => (
|
||||
<div
|
||||
key={`${metadataFieldId}-principal-${principal}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={principal}
|
||||
onValueChange={(newValue) => {
|
||||
if (value.includes(newValue)) {
|
||||
createNotification({
|
||||
text: "This principal is already added",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const newPrincipals = [...value];
|
||||
newPrincipals[principalIndex] = newValue;
|
||||
onChange(newPrincipals);
|
||||
}}
|
||||
placeholder="Select a member"
|
||||
className="w-full"
|
||||
>
|
||||
{members.map((member) => (
|
||||
<SelectItem
|
||||
key={member.user.id}
|
||||
value={member.user.username}
|
||||
>
|
||||
{member.user.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{value.map((principal, principalIndex) => (
|
||||
<div
|
||||
key={`${metadataFieldId}-principal-${principalIndex + 1}`}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="mr-2">
|
||||
<Select
|
||||
className="w-24"
|
||||
value={principal.type}
|
||||
onValueChange={(newType) => {
|
||||
const newPrincipals = [...value];
|
||||
newPrincipals[principalIndex] = {
|
||||
type: newType as "user" | "group",
|
||||
value: ""
|
||||
};
|
||||
onChange(newPrincipals);
|
||||
}}
|
||||
>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={principal.value}
|
||||
onValueChange={(newValue) => {
|
||||
if (isPrincipalDuplicate(i, principal.type, newValue)) {
|
||||
createNotification({
|
||||
text: `This ${principal.type} is already added`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
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
|
||||
size="sm"
|
||||
ariaLabel="delete principal"
|
||||
@@ -421,8 +485,8 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error && <span className="text-sm text-red-500">{error.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
faEllipsis,
|
||||
faPencil,
|
||||
faServer,
|
||||
faTrash
|
||||
faTrash,
|
||||
faUser,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import FileSaver from "file-saver";
|
||||
@@ -12,6 +14,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -91,13 +94,46 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
|
||||
<span className="italic text-mineshaft-400">None</span>
|
||||
) : (
|
||||
host.loginMappings.map(({ loginUser, allowedPrincipals }) => (
|
||||
<div key={`${host.id}-${loginUser}`} className="mb-2">
|
||||
<div className="text-mineshaft-200">{loginUser}</div>
|
||||
{allowedPrincipals.usernames.map((username) => (
|
||||
<div key={`${host.id}-${loginUser}-${username}`} className="ml-4">
|
||||
└─ {username}
|
||||
</div>
|
||||
))}
|
||||
<div key={`${host.id}-${loginUser}`} className="mb-3">
|
||||
<div className="mb-1 text-mineshaft-200">{loginUser}</div>
|
||||
<div className="ml-4 flex flex-col gap-1">
|
||||
{allowedPrincipals.usernames.map((username) => (
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user