mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
misc: setup prerequisites for terraform project group
This commit is contained in:
@@ -28,14 +28,36 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
|||||||
projectSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectSlug),
|
projectSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectSlug),
|
||||||
groupSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupSlug)
|
groupSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupSlug)
|
||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z
|
||||||
role: z
|
.object({
|
||||||
.string()
|
role: z
|
||||||
.trim()
|
.string()
|
||||||
.min(1)
|
.trim()
|
||||||
.default(ProjectMembershipRole.NoAccess)
|
.min(1)
|
||||||
.describe(PROJECTS.ADD_GROUP_TO_PROJECT.role)
|
.default(ProjectMembershipRole.NoAccess)
|
||||||
}),
|
.describe(PROJECTS.ADD_GROUP_TO_PROJECT.role),
|
||||||
|
roles: z
|
||||||
|
.array(
|
||||||
|
z.union([
|
||||||
|
z.object({
|
||||||
|
role: z.string(),
|
||||||
|
isTemporary: z.literal(false).default(false)
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
role: z.string(),
|
||||||
|
isTemporary: z.literal(true),
|
||||||
|
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
|
||||||
|
temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"),
|
||||||
|
temporaryAccessStartTime: z.string().datetime()
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.refine((data) => data.role || data.roles, {
|
||||||
|
message: "Either role or roles must be present",
|
||||||
|
path: ["role", "roles"]
|
||||||
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
groupMembership: GroupProjectMembershipsSchema
|
groupMembership: GroupProjectMembershipsSchema
|
||||||
@@ -50,8 +72,9 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
|||||||
actorOrgId: req.permission.orgId,
|
actorOrgId: req.permission.orgId,
|
||||||
groupSlug: req.params.groupSlug,
|
groupSlug: req.params.groupSlug,
|
||||||
projectSlug: req.params.projectSlug,
|
projectSlug: req.params.projectSlug,
|
||||||
role: req.body.role
|
roles: req.body.roles || [{ role: req.body.role }]
|
||||||
});
|
});
|
||||||
|
|
||||||
return { groupMembership };
|
return { groupMembership };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -198,4 +221,58 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
|||||||
return { groupMemberships };
|
return { groupMemberships };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/:projectSlug/groups/:groupSlug",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
schema: {
|
||||||
|
description: "Return project group",
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
bearerAuth: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
params: z.object({
|
||||||
|
projectSlug: z.string().trim(),
|
||||||
|
groupSlug: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
groupMembership: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
groupId: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
roles: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
customRoleId: z.string().optional().nullable(),
|
||||||
|
customRoleName: z.string().optional().nullable(),
|
||||||
|
customRoleSlug: z.string().optional().nullable(),
|
||||||
|
isTemporary: z.boolean(),
|
||||||
|
temporaryMode: z.string().optional().nullable(),
|
||||||
|
temporaryRange: z.string().nullable().optional(),
|
||||||
|
temporaryAccessStartTime: z.date().nullable().optional(),
|
||||||
|
temporaryAccessEndTime: z.date().nullable().optional()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
group: GroupsSchema.pick({ name: true, id: true, slug: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const groupMembership = await server.services.groupProject.getGroupInProject({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.params
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupMembership };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,109 @@ export type TGroupProjectDALFactory = ReturnType<typeof groupProjectDALFactory>;
|
|||||||
export const groupProjectDALFactory = (db: TDbClient) => {
|
export const groupProjectDALFactory = (db: TDbClient) => {
|
||||||
const groupProjectOrm = ormify(db, TableName.GroupProjectMembership);
|
const groupProjectOrm = ormify(db, TableName.GroupProjectMembership);
|
||||||
|
|
||||||
|
const findByGroupSlugAndProject = async (
|
||||||
|
{ groupSlug, projectId }: { groupSlug: string; projectId: string },
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// this is a workaround so that the order of group roles is preserved as a necessary
|
||||||
|
// requirement for our terraform provider
|
||||||
|
|
||||||
|
/* Approach:
|
||||||
|
1. First we query the group project membership along with the attached roles
|
||||||
|
2. Then we have a separate query to fetch the custom role details which we manually augment below
|
||||||
|
*/
|
||||||
|
const rawGroupResult = await (tx || db.replicaNode())(TableName.GroupProjectMembership)
|
||||||
|
.where(`${TableName.GroupProjectMembership}.projectId`, projectId)
|
||||||
|
.where(`${TableName.Groups}.slug`, "=", groupSlug)
|
||||||
|
.join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`)
|
||||||
|
.join(
|
||||||
|
TableName.GroupProjectMembershipRole,
|
||||||
|
`${TableName.GroupProjectMembershipRole}.projectMembershipId`,
|
||||||
|
`${TableName.GroupProjectMembership}.id`
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
db.ref("id").withSchema(TableName.GroupProjectMembership),
|
||||||
|
db.ref("createdAt").withSchema(TableName.GroupProjectMembership),
|
||||||
|
db.ref("updatedAt").withSchema(TableName.GroupProjectMembership),
|
||||||
|
db.ref("id").as("groupId").withSchema(TableName.Groups),
|
||||||
|
db.ref("name").as("groupName").withSchema(TableName.Groups),
|
||||||
|
db.ref("slug").as("groupSlug").withSchema(TableName.Groups),
|
||||||
|
db.ref("id").withSchema(TableName.GroupProjectMembership),
|
||||||
|
db.ref("role").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
|
db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"),
|
||||||
|
db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
|
db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
|
db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
|
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
|
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
|
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
// IMPORTANT NOTE: we do this separately because this breaks the order of the group project roles
|
||||||
|
const customRoleDetails = await (tx || db.replicaNode())(TableName.ProjectRoles)
|
||||||
|
.whereIn("id", rawGroupResult.map((entry) => entry.customRoleId) as string[])
|
||||||
|
.select("id", "slug", "name");
|
||||||
|
|
||||||
|
const groupProjectMembershipRoleToCustomRole: Record<string, { name: string; slug: string }> = {};
|
||||||
|
customRoleDetails.forEach(
|
||||||
|
// eslint-disable-next-line no-return-assign
|
||||||
|
(entry) =>
|
||||||
|
(groupProjectMembershipRoleToCustomRole[entry.id] = {
|
||||||
|
name: entry.name,
|
||||||
|
slug: entry.slug
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const members = sqlNestRelationships({
|
||||||
|
data: rawGroupResult,
|
||||||
|
parentMapper: ({ groupId, groupName, id, createdAt, updatedAt }) => ({
|
||||||
|
id,
|
||||||
|
groupId,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
group: {
|
||||||
|
id: groupId,
|
||||||
|
name: groupName,
|
||||||
|
slug: groupSlug
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
key: "id",
|
||||||
|
childrenMapper: [
|
||||||
|
{
|
||||||
|
label: "roles" as const,
|
||||||
|
key: "membershipRoleId",
|
||||||
|
mapper: ({
|
||||||
|
role,
|
||||||
|
customRoleId,
|
||||||
|
membershipRoleId,
|
||||||
|
temporaryRange,
|
||||||
|
temporaryMode,
|
||||||
|
temporaryAccessEndTime,
|
||||||
|
temporaryAccessStartTime,
|
||||||
|
isTemporary
|
||||||
|
}) => ({
|
||||||
|
id: membershipRoleId,
|
||||||
|
role,
|
||||||
|
customRoleId,
|
||||||
|
customRoleName: customRoleId && groupProjectMembershipRoleToCustomRole[customRoleId]?.name,
|
||||||
|
customRoleSlug: customRoleId && groupProjectMembershipRoleToCustomRole[customRoleId]?.slug,
|
||||||
|
temporaryRange,
|
||||||
|
temporaryMode,
|
||||||
|
temporaryAccessEndTime,
|
||||||
|
temporaryAccessStartTime,
|
||||||
|
isTemporary
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return members[0];
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "FindByGroupSlugAndProject" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const findByProjectId = async (projectId: string, tx?: Knex) => {
|
const findByProjectId = async (projectId: string, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const docs = await (tx || db.replicaNode())(TableName.GroupProjectMembership)
|
const docs = await (tx || db.replicaNode())(TableName.GroupProjectMembership)
|
||||||
@@ -221,5 +324,5 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
|||||||
return members;
|
return members;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers };
|
return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers, findByGroupSlugAndProject };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services
|
|||||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||||
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
||||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { groupBy } from "@app/lib/fn";
|
import { groupBy } from "@app/lib/fn";
|
||||||
|
|
||||||
import { TGroupDALFactory } from "../../ee/services/group/group-dal";
|
import { TGroupDALFactory } from "../../ee/services/group/group-dal";
|
||||||
@@ -22,12 +22,16 @@ import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membershi
|
|||||||
import {
|
import {
|
||||||
TCreateProjectGroupDTO,
|
TCreateProjectGroupDTO,
|
||||||
TDeleteProjectGroupDTO,
|
TDeleteProjectGroupDTO,
|
||||||
|
TGetGroupInProjectDTO,
|
||||||
TListProjectGroupDTO,
|
TListProjectGroupDTO,
|
||||||
TUpdateProjectGroupDTO
|
TUpdateProjectGroupDTO
|
||||||
} from "./group-project-types";
|
} from "./group-project-types";
|
||||||
|
|
||||||
type TGroupProjectServiceFactoryDep = {
|
type TGroupProjectServiceFactoryDep = {
|
||||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "findOne" | "transaction" | "create" | "delete" | "findByProjectId">;
|
groupProjectDAL: Pick<
|
||||||
|
TGroupProjectDALFactory,
|
||||||
|
"findOne" | "transaction" | "create" | "delete" | "findByProjectId" | "findByGroupSlugAndProject"
|
||||||
|
>;
|
||||||
groupProjectMembershipRoleDAL: Pick<
|
groupProjectMembershipRoleDAL: Pick<
|
||||||
TGroupProjectMembershipRoleDALFactory,
|
TGroupProjectMembershipRoleDALFactory,
|
||||||
"create" | "transaction" | "insertMany" | "delete"
|
"create" | "transaction" | "insertMany" | "delete"
|
||||||
@@ -61,7 +65,7 @@ export const groupProjectServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
role
|
roles
|
||||||
}: TCreateProjectGroupDTO) => {
|
}: TCreateProjectGroupDTO) => {
|
||||||
const project = await projectDAL.findOne({
|
const project = await projectDAL.findOne({
|
||||||
slug: projectSlug
|
slug: projectSlug
|
||||||
@@ -88,16 +92,35 @@ export const groupProjectServiceFactory = ({
|
|||||||
message: `Group with slug ${groupSlug} already exists in project with id ${project.id}`
|
message: `Group with slug ${groupSlug} already exists in project with id ${project.id}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole(
|
for await (const { role: requestedRoleChange } of roles) {
|
||||||
role,
|
const { permission: rolePermission } = await permissionService.getProjectPermissionByRole(
|
||||||
project.id
|
requestedRoleChange,
|
||||||
|
project.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||||
|
|
||||||
|
if (!hasRequiredPriviledges) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate custom roles input
|
||||||
|
const customInputRoles = roles.filter(
|
||||||
|
({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
|
||||||
);
|
);
|
||||||
const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission);
|
const hasCustomRole = Boolean(customInputRoles.length);
|
||||||
if (!hasPrivilege)
|
const customRoles = hasCustomRole
|
||||||
throw new ForbiddenRequestError({
|
? await projectRoleDAL.find({
|
||||||
message: "Failed to add group to project with more privileged role"
|
projectId: project.id,
|
||||||
});
|
$in: { slug: customInputRoles.map(({ role }) => role) }
|
||||||
const isCustomRole = Boolean(customRole);
|
})
|
||||||
|
: [];
|
||||||
|
if (customRoles.length !== customInputRoles.length) {
|
||||||
|
throw new NotFoundError({ message: "Custom role not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
|
||||||
|
|
||||||
const projectGroup = await groupProjectDAL.transaction(async (tx) => {
|
const projectGroup = await groupProjectDAL.transaction(async (tx) => {
|
||||||
const groupProjectMembership = await groupProjectDAL.create(
|
const groupProjectMembership = await groupProjectDAL.create(
|
||||||
@@ -108,14 +131,31 @@ export const groupProjectServiceFactory = ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
await groupProjectMembershipRoleDAL.create(
|
const sanitizedProjectMembershipRoles = roles.map((inputRole) => {
|
||||||
{
|
const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]);
|
||||||
|
if (!inputRole.isTemporary) {
|
||||||
|
return {
|
||||||
|
projectMembershipId: groupProjectMembership.id,
|
||||||
|
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
|
||||||
|
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// check cron or relative here later for now its just relative
|
||||||
|
const relativeTimeInMs = ms(inputRole.temporaryRange);
|
||||||
|
return {
|
||||||
projectMembershipId: groupProjectMembership.id,
|
projectMembershipId: groupProjectMembership.id,
|
||||||
role: isCustomRole ? ProjectMembershipRole.Custom : role,
|
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
|
||||||
customRoleId: customRole?.id
|
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null,
|
||||||
},
|
isTemporary: true,
|
||||||
tx
|
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||||
);
|
temporaryRange: inputRole.temporaryRange,
|
||||||
|
temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime),
|
||||||
|
temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx);
|
||||||
|
|
||||||
// share project key with users in group that have not
|
// share project key with users in group that have not
|
||||||
// individually been added to the project and that are not part of
|
// individually been added to the project and that are not part of
|
||||||
@@ -336,10 +376,44 @@ export const groupProjectServiceFactory = ({
|
|||||||
return groupMemberships;
|
return groupMemberships;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getGroupInProject = async ({
|
||||||
|
projectSlug,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
groupSlug
|
||||||
|
}: TGetGroupInProjectDTO) => {
|
||||||
|
const project = await projectDAL.findOne({
|
||||||
|
slug: projectSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundError({ message: `Failed to find project with slug ${projectSlug}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups);
|
||||||
|
|
||||||
|
const groupMembership = await groupProjectDAL.findByGroupSlugAndProject({
|
||||||
|
groupSlug,
|
||||||
|
projectId: project.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return groupMembership;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addGroupToProject,
|
addGroupToProject,
|
||||||
updateGroupInProject,
|
updateGroupInProject,
|
||||||
removeGroupFromProject,
|
removeGroupFromProject,
|
||||||
listGroupsInProject
|
listGroupsInProject,
|
||||||
|
getGroupInProject
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ import { ProjectUserMembershipTemporaryMode } from "../project-membership/projec
|
|||||||
|
|
||||||
export type TCreateProjectGroupDTO = {
|
export type TCreateProjectGroupDTO = {
|
||||||
groupSlug: string;
|
groupSlug: string;
|
||||||
role: string;
|
roles: (
|
||||||
|
| {
|
||||||
|
role: string;
|
||||||
|
isTemporary?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
role: string;
|
||||||
|
isTemporary: true;
|
||||||
|
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
|
||||||
|
temporaryRange: string;
|
||||||
|
temporaryAccessStartTime: string;
|
||||||
|
}
|
||||||
|
)[];
|
||||||
} & TProjectSlugPermission;
|
} & TProjectSlugPermission;
|
||||||
|
|
||||||
export type TUpdateProjectGroupDTO = {
|
export type TUpdateProjectGroupDTO = {
|
||||||
@@ -29,3 +41,5 @@ export type TDeleteProjectGroupDTO = {
|
|||||||
} & TProjectSlugPermission;
|
} & TProjectSlugPermission;
|
||||||
|
|
||||||
export type TListProjectGroupDTO = TProjectSlugPermission;
|
export type TListProjectGroupDTO = TProjectSlugPermission;
|
||||||
|
|
||||||
|
export type TGetGroupInProjectDTO = TProjectSlugPermission & { groupSlug: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user