mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 11:27:47 +00:00
misc: unbinded scim from saml
This commit is contained in:
@@ -18,6 +18,7 @@ import { TGroupProjectDALFactory } from "@app/services/group-project/group-proje
|
|||||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||||
import { deleteOrgMembershipFn } from "@app/services/org/org-fns";
|
import { deleteOrgMembershipFn } from "@app/services/org/org-fns";
|
||||||
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
|
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
|
||||||
|
import { OrgAuthMethod } from "@app/services/org/org-types";
|
||||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||||
@@ -71,6 +72,7 @@ type TScimServiceFactoryDep = {
|
|||||||
| "deleteMembershipById"
|
| "deleteMembershipById"
|
||||||
| "transaction"
|
| "transaction"
|
||||||
| "updateMembershipById"
|
| "updateMembershipById"
|
||||||
|
| "findOrgById"
|
||||||
>;
|
>;
|
||||||
orgMembershipDAL: Pick<
|
orgMembershipDAL: Pick<
|
||||||
TOrgMembershipDALFactory,
|
TOrgMembershipDALFactory,
|
||||||
@@ -288,8 +290,7 @@ export const scimServiceFactory = ({
|
|||||||
const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => {
|
const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => {
|
||||||
if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 });
|
if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 });
|
||||||
|
|
||||||
const org = await orgDAL.findById(orgId);
|
const org = await orgDAL.findOrgById(orgId);
|
||||||
|
|
||||||
if (!org)
|
if (!org)
|
||||||
throw new ScimRequestError({
|
throw new ScimRequestError({
|
||||||
detail: "Organization not found",
|
detail: "Organization not found",
|
||||||
@@ -302,13 +303,24 @@ export const scimServiceFactory = ({
|
|||||||
status: 403
|
status: 403
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!org.orgAuthMethod) {
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "Neither SAML or OIDC SSO is configured",
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
const serverCfg = await getServerCfg();
|
const serverCfg = await getServerCfg();
|
||||||
|
|
||||||
|
const aliasType = org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML;
|
||||||
|
const trustScimEmails =
|
||||||
|
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails;
|
||||||
|
|
||||||
const userAlias = await userAliasDAL.findOne({
|
const userAlias = await userAliasDAL.findOne({
|
||||||
externalId,
|
externalId,
|
||||||
orgId,
|
orgId,
|
||||||
aliasType: UserAliasType.SAML
|
aliasType
|
||||||
});
|
});
|
||||||
|
|
||||||
const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => {
|
const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => {
|
||||||
@@ -349,7 +361,7 @@ export const scimServiceFactory = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (serverCfg.trustSamlEmails) {
|
if (trustScimEmails) {
|
||||||
user = await userDAL.findOne(
|
user = await userDAL.findOne(
|
||||||
{
|
{
|
||||||
email,
|
email,
|
||||||
@@ -367,9 +379,9 @@ export const scimServiceFactory = ({
|
|||||||
);
|
);
|
||||||
user = await userDAL.create(
|
user = await userDAL.create(
|
||||||
{
|
{
|
||||||
username: serverCfg.trustSamlEmails ? email : uniqueUsername,
|
username: trustScimEmails ? email : uniqueUsername,
|
||||||
email,
|
email,
|
||||||
isEmailVerified: serverCfg.trustSamlEmails,
|
isEmailVerified: trustScimEmails,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
authMethods: [],
|
authMethods: [],
|
||||||
@@ -382,7 +394,7 @@ export const scimServiceFactory = ({
|
|||||||
await userAliasDAL.create(
|
await userAliasDAL.create(
|
||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
aliasType: UserAliasType.SAML,
|
aliasType,
|
||||||
externalId,
|
externalId,
|
||||||
emails: email ? [email] : [],
|
emails: email ? [email] : [],
|
||||||
orgId
|
orgId
|
||||||
@@ -437,7 +449,7 @@ export const scimServiceFactory = ({
|
|||||||
recipients: [email],
|
recipients: [email],
|
||||||
substitutions: {
|
substitutions: {
|
||||||
organizationName: org.name,
|
organizationName: org.name,
|
||||||
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
|
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/organizations/${org.slug}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -456,6 +468,14 @@ export const scimServiceFactory = ({
|
|||||||
|
|
||||||
// partial
|
// partial
|
||||||
const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => {
|
const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => {
|
||||||
|
const org = await orgDAL.findOrgById(orgId);
|
||||||
|
if (!org.orgAuthMethod) {
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "Neither SAML or OIDC SSO is configured",
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [membership] = await orgDAL
|
const [membership] = await orgDAL
|
||||||
.findMembership({
|
.findMembership({
|
||||||
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
|
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
|
||||||
@@ -493,6 +513,9 @@ export const scimServiceFactory = ({
|
|||||||
scimPatch(scimUser, operations);
|
scimPatch(scimUser, operations);
|
||||||
|
|
||||||
const serverCfg = await getServerCfg();
|
const serverCfg = await getServerCfg();
|
||||||
|
const trustScimEmails =
|
||||||
|
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails;
|
||||||
|
|
||||||
await userDAL.transaction(async (tx) => {
|
await userDAL.transaction(async (tx) => {
|
||||||
await orgMembershipDAL.updateById(
|
await orgMembershipDAL.updateById(
|
||||||
membership.id,
|
membership.id,
|
||||||
@@ -508,7 +531,7 @@ export const scimServiceFactory = ({
|
|||||||
firstName: scimUser.name.givenName,
|
firstName: scimUser.name.givenName,
|
||||||
email: scimUser.emails[0].value,
|
email: scimUser.emails[0].value,
|
||||||
lastName: scimUser.name.familyName,
|
lastName: scimUser.name.familyName,
|
||||||
isEmailVerified: hasEmailChanged ? serverCfg.trustSamlEmails : true
|
isEmailVerified: hasEmailChanged ? trustScimEmails : true
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
@@ -526,6 +549,14 @@ export const scimServiceFactory = ({
|
|||||||
email,
|
email,
|
||||||
externalId
|
externalId
|
||||||
}: TReplaceScimUserDTO) => {
|
}: TReplaceScimUserDTO) => {
|
||||||
|
const org = await orgDAL.findOrgById(orgId);
|
||||||
|
if (!org.orgAuthMethod) {
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "Neither SAML or OIDC SSO is configured",
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [membership] = await orgDAL
|
const [membership] = await orgDAL
|
||||||
.findMembership({
|
.findMembership({
|
||||||
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
|
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
|
||||||
@@ -555,7 +586,7 @@ export const scimServiceFactory = ({
|
|||||||
await userAliasDAL.update(
|
await userAliasDAL.update(
|
||||||
{
|
{
|
||||||
orgId,
|
orgId,
|
||||||
aliasType: UserAliasType.SAML,
|
aliasType: org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML,
|
||||||
userId: membership.userId
|
userId: membership.userId
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -576,7 +607,8 @@ export const scimServiceFactory = ({
|
|||||||
firstName,
|
firstName,
|
||||||
email,
|
email,
|
||||||
lastName,
|
lastName,
|
||||||
isEmailVerified: serverCfg.trustSamlEmails
|
isEmailVerified:
|
||||||
|
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { fetchGithubEmails } from "@app/lib/requests/github";
|
import { fetchGithubEmails } from "@app/lib/requests/github";
|
||||||
|
import { authRateLimit } from "@app/server/config/rateLimiter";
|
||||||
import { AuthMethod } from "@app/services/auth/auth-type";
|
import { AuthMethod } from "@app/services/auth/auth-type";
|
||||||
|
import { OrgAuthMethod } from "@app/services/org/org-types";
|
||||||
|
|
||||||
export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
@@ -196,6 +198,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
|||||||
handler: () => {}
|
handler: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/redirect/organizations/:orgSlug",
|
||||||
|
method: "GET",
|
||||||
|
config: {
|
||||||
|
rateLimit: authRateLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
orgSlug: z.string().trim()
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
callback_port: z.string().optional()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handler: async (req, res) => {
|
||||||
|
const org = await server.services.org.findOrgBySlug(req.params.orgSlug);
|
||||||
|
if (org.orgAuthMethod === OrgAuthMethod.SAML) {
|
||||||
|
return res.redirect(
|
||||||
|
`${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}?${
|
||||||
|
req.query.callback_port ? `callback_port=${req.query.callback_port}` : ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.orgAuthMethod === OrgAuthMethod.OIDC) {
|
||||||
|
return res.redirect(
|
||||||
|
`${appCfg.SITE_URL}/api/v1/sso/oidc/login?orgSlug=${org.slug}${
|
||||||
|
req.query.callback_port ? `&callbackPort=${req.query.callback_port}` : ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "The organization does not have any SSO configured."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
url: "/github",
|
url: "/github",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { DatabaseError } from "@app/lib/errors";
|
|||||||
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex";
|
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex";
|
||||||
import { generateKnexQueryFromScim } from "@app/lib/knex/scim";
|
import { generateKnexQueryFromScim } from "@app/lib/knex/scim";
|
||||||
|
|
||||||
|
import { OrgAuthMethod } from "./org-types";
|
||||||
|
|
||||||
export type TOrgDALFactory = ReturnType<typeof orgDALFactory>;
|
export type TOrgDALFactory = ReturnType<typeof orgDALFactory>;
|
||||||
|
|
||||||
export const orgDALFactory = (db: TDbClient) => {
|
export const orgDALFactory = (db: TDbClient) => {
|
||||||
@@ -21,13 +23,78 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
|
|
||||||
const findOrgById = async (orgId: string) => {
|
const findOrgById = async (orgId: string) => {
|
||||||
try {
|
try {
|
||||||
const org = await db.replicaNode()(TableName.Organization).where({ id: orgId }).first();
|
const org = (await db
|
||||||
|
.replicaNode()(TableName.Organization)
|
||||||
|
.where({ [`${TableName.Organization}.id` as "id"]: orgId })
|
||||||
|
.leftJoin(TableName.SamlConfig, (qb) => {
|
||||||
|
qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
|
||||||
|
`${TableName.SamlConfig}.isActive`,
|
||||||
|
"=",
|
||||||
|
db.raw("true")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.leftJoin(TableName.OidcConfig, (qb) => {
|
||||||
|
qb.on(`${TableName.OidcConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
|
||||||
|
`${TableName.OidcConfig}.isActive`,
|
||||||
|
"=",
|
||||||
|
db.raw("true")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.select(selectAllTableCols(TableName.Organization))
|
||||||
|
.select(
|
||||||
|
db.raw(`
|
||||||
|
CASE
|
||||||
|
WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}'
|
||||||
|
WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}'
|
||||||
|
ELSE ''
|
||||||
|
END as "orgAuthMethod"
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
.first()) as TOrganizations & { orgAuthMethod?: string };
|
||||||
|
|
||||||
return org;
|
return org;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({ error, name: "Find org by id" });
|
throw new DatabaseError({ error, name: "Find org by id" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findOrgBySlug = async (orgSlug: string) => {
|
||||||
|
try {
|
||||||
|
const org = (await db
|
||||||
|
.replicaNode()(TableName.Organization)
|
||||||
|
.where({ [`${TableName.Organization}.slug` as "slug"]: orgSlug })
|
||||||
|
.leftJoin(TableName.SamlConfig, (qb) => {
|
||||||
|
qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
|
||||||
|
`${TableName.SamlConfig}.isActive`,
|
||||||
|
"=",
|
||||||
|
db.raw("true")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.leftJoin(TableName.OidcConfig, (qb) => {
|
||||||
|
qb.on(`${TableName.OidcConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
|
||||||
|
`${TableName.OidcConfig}.isActive`,
|
||||||
|
"=",
|
||||||
|
db.raw("true")
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.select(selectAllTableCols(TableName.Organization))
|
||||||
|
.select(
|
||||||
|
db.raw(`
|
||||||
|
CASE
|
||||||
|
WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}'
|
||||||
|
WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}'
|
||||||
|
ELSE ''
|
||||||
|
END as "orgAuthMethod"
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
.first()) as TOrganizations & { orgAuthMethod?: string };
|
||||||
|
|
||||||
|
return org;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find org by slug" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// special query
|
// special query
|
||||||
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
|
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
|
||||||
try {
|
try {
|
||||||
@@ -398,6 +465,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
findAllOrgMembers,
|
findAllOrgMembers,
|
||||||
countAllOrgMembers,
|
countAllOrgMembers,
|
||||||
findOrgById,
|
findOrgById,
|
||||||
|
findOrgBySlug,
|
||||||
findAllOrgsByUserId,
|
findAllOrgsByUserId,
|
||||||
ghostUserExists,
|
ghostUserExists,
|
||||||
findOrgMembersByUsername,
|
findOrgMembersByUsername,
|
||||||
|
|||||||
@@ -187,6 +187,15 @@ export const orgServiceFactory = ({
|
|||||||
return members;
|
return members;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findOrgBySlug = async (slug: string) => {
|
||||||
|
const org = await orgDAL.findOrgBySlug(slug);
|
||||||
|
if (!org) {
|
||||||
|
throw new NotFoundError({ message: `Organization with slug '${slug}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return org;
|
||||||
|
};
|
||||||
|
|
||||||
const findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => {
|
const findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => {
|
||||||
const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id));
|
const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id));
|
||||||
|
|
||||||
@@ -275,6 +284,7 @@ export const orgServiceFactory = ({
|
|||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
||||||
|
|
||||||
const plan = await licenseService.getPlan(orgId);
|
const plan = await licenseService.getPlan(orgId);
|
||||||
|
const currentOrg = await orgDAL.findOrgById(actorOrgId);
|
||||||
|
|
||||||
if (enforceMfa !== undefined) {
|
if (enforceMfa !== undefined) {
|
||||||
if (!plan.enforceMfa) {
|
if (!plan.enforceMfa) {
|
||||||
@@ -305,6 +315,11 @@ export const orgServiceFactory = ({
|
|||||||
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
|
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
|
||||||
});
|
});
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
|
||||||
|
if (scimEnabled && !currentOrg.orgAuthMethod) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Cannot enable SCIM when neither SAML or OIDC is configured."
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authEnforced) {
|
if (authEnforced) {
|
||||||
@@ -1132,6 +1147,7 @@ export const orgServiceFactory = ({
|
|||||||
createIncidentContact,
|
createIncidentContact,
|
||||||
deleteIncidentContact,
|
deleteIncidentContact,
|
||||||
getOrgGroups,
|
getOrgGroups,
|
||||||
listProjectMembershipsByOrgMembershipId
|
listProjectMembershipsByOrgMembershipId,
|
||||||
|
findOrgBySlug
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,3 +74,8 @@ export type TGetOrgGroupsDTO = TOrgPermission;
|
|||||||
export type TListProjectMembershipsByOrgMembershipIdDTO = {
|
export type TListProjectMembershipsByOrgMembershipIdDTO = {
|
||||||
orgMembershipId: string;
|
orgMembershipId: string;
|
||||||
} & TOrgPermission;
|
} & TOrgPermission;
|
||||||
|
|
||||||
|
export enum OrgAuthMethod {
|
||||||
|
OIDC = "oidc",
|
||||||
|
SAML = "saml"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user