mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-08 23:18:05 -05:00
reduce code reusability
This commit is contained in:
@@ -49,6 +49,23 @@ export const convertVaultValueToString = (value: JsonValue): string => {
|
||||
// Concurrency limit for HC Vault API requests to avoid rate limiting
|
||||
const HC_VAULT_CONCURRENCY_LIMIT = 20;
|
||||
|
||||
// Helper to check if error is a 404
|
||||
const isVault404Error = (error: unknown): boolean => {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
return axiosError.response?.status === 404;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Helper to extract error message from Vault API errors
|
||||
const getVaultErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof AxiosError) {
|
||||
return (error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a concurrency limiter that restricts the number of concurrent async operations
|
||||
* @param limit - Maximum number of concurrent operations
|
||||
@@ -866,14 +883,7 @@ export const getHCVaultKubernetesRoles = async (
|
||||
);
|
||||
roleNames = roleListResponse.data.keys || [];
|
||||
} catch (error) {
|
||||
// Vault returns 404 when no roles are configured yet
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
if (axiosError.response?.status === 404) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (isVault404Error(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -910,7 +920,6 @@ export const getHCVaultKubernetesRoles = async (
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Merge the role with the config
|
||||
return {
|
||||
...roleResponse.data,
|
||||
name: roleName,
|
||||
@@ -920,22 +929,11 @@ export const getHCVaultKubernetesRoles = async (
|
||||
})
|
||||
);
|
||||
|
||||
const roles = await Promise.all(roleDetailsPromises);
|
||||
|
||||
return roles;
|
||||
return Promise.all(roleDetailsPromises);
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to list HC Vault Kubernetes secrets engine roles");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error";
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list Kubernetes secrets engine roles: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to list Kubernetes secrets engine roles from HashiCorp Vault"
|
||||
message: `Failed to list Kubernetes secrets engine roles: ${getVaultErrorMessage(error, "Unknown error")}`
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -970,13 +968,7 @@ export const getHCVaultDatabaseRoles = async (
|
||||
);
|
||||
connectionNames = connectionListResponse.data.keys || [];
|
||||
} catch (error) {
|
||||
// Vault returns 404 when no connections are configured yet
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
if (axiosError.response?.status === 404) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (isVault404Error(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1023,13 +1015,7 @@ export const getHCVaultDatabaseRoles = async (
|
||||
);
|
||||
roleNames = roleListResponse.data.keys || [];
|
||||
} catch (error) {
|
||||
// Vault returns 404 when no roles are configured yet
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
if (axiosError.response?.status === 404) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (isVault404Error(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1047,13 +1033,7 @@ export const getHCVaultDatabaseRoles = async (
|
||||
max_ttl?: number;
|
||||
creation_statements?: string[];
|
||||
revocation_statements?: string[];
|
||||
rollback_statements?: string[];
|
||||
renew_statements?: string[];
|
||||
rotation_statements?: string[];
|
||||
credential_type?: string;
|
||||
credential_config?: {
|
||||
password_policy?: string;
|
||||
};
|
||||
};
|
||||
}>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/${cleanMountPath}/roles/${roleName}`,
|
||||
@@ -1064,12 +1044,9 @@ export const getHCVaultDatabaseRoles = async (
|
||||
}
|
||||
});
|
||||
|
||||
// Get the connection config for this role's db_name
|
||||
const dbConfig = connectionConfigs.get(roleResponse.data.db_name) || {
|
||||
plugin_name: "",
|
||||
connection_details: {
|
||||
connection_url: ""
|
||||
}
|
||||
connection_details: { connection_url: "" }
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1086,22 +1063,11 @@ export const getHCVaultDatabaseRoles = async (
|
||||
})
|
||||
);
|
||||
|
||||
const roles = await Promise.all(roleDetailsPromises);
|
||||
|
||||
return roles;
|
||||
return Promise.all(roleDetailsPromises);
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to list HC Vault database secrets engine roles");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error";
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list database secrets engine roles: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to list database secrets engine roles from HashiCorp Vault"
|
||||
message: `Failed to list database secrets engine roles: ${getVaultErrorMessage(error, "Unknown error")}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,6 +80,47 @@ export const externalMigrationServiceFactory = ({
|
||||
vaultExternalMigrationConfigDAL,
|
||||
kmsService
|
||||
}: TExternalMigrationServiceFactoryDep) => {
|
||||
// Helper to verify admin permissions and get vault connection
|
||||
const getVaultConnectionForNamespace = async (actor: OrgServiceActor, namespace: string, action: string) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
orgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: `Only admins can ${action}` });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
return {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
};
|
||||
|
||||
const importEnvKeyData = async ({
|
||||
decryptionKey,
|
||||
encryptedJson,
|
||||
@@ -390,89 +431,13 @@ export const externalMigrationServiceFactory = ({
|
||||
};
|
||||
|
||||
const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
orgId: actor.orgId,
|
||||
actorOrgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
scope: OrganizationActionScope.Any
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault policies" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const policies = await listHCVaultPolicies(namespace, connection, gatewayService);
|
||||
return policies;
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "view vault policies");
|
||||
return listHCVaultPolicies(namespace, connection, gatewayService);
|
||||
};
|
||||
|
||||
const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
orgId: actor.orgId,
|
||||
actorOrgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
scope: OrganizationActionScope.Any
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
|
||||
return mounts;
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "view vault mounts");
|
||||
return listHCVaultMounts(connection, gatewayService, namespace);
|
||||
};
|
||||
|
||||
const getVaultSecretPaths = async ({
|
||||
@@ -484,47 +449,8 @@ export const externalMigrationServiceFactory = ({
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
orgId: actor.orgId,
|
||||
actorOrgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
scope: OrganizationActionScope.Any
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath);
|
||||
|
||||
return secretPaths;
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "view vault secret paths");
|
||||
return listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath);
|
||||
};
|
||||
|
||||
const importVaultSecrets = async ({
|
||||
@@ -544,44 +470,7 @@ export const externalMigrationServiceFactory = ({
|
||||
vaultSecretPath: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
orgId: actor.orgId,
|
||||
actorOrgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
scope: OrganizationActionScope.Any
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace: vaultNamespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const connection = await getVaultConnectionForNamespace(actor, vaultNamespace, "import vault secrets");
|
||||
const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService);
|
||||
|
||||
try {
|
||||
@@ -668,47 +557,8 @@ export const externalMigrationServiceFactory = ({
|
||||
namespace: string;
|
||||
authType?: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
orgId: actor.orgId,
|
||||
actorOrgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
scope: OrganizationActionScope.Any
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService);
|
||||
|
||||
return authMounts;
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "view vault auth mounts");
|
||||
return getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService);
|
||||
};
|
||||
|
||||
const getVaultKubernetesAuthRoles = async ({
|
||||
@@ -720,48 +570,8 @@ export const externalMigrationServiceFactory = ({
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
actorId: actor.id,
|
||||
actor: actor.type,
|
||||
orgId: actor.orgId,
|
||||
actorOrgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
scope: OrganizationActionScope.Any
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
// Get roles for the specified mount path only
|
||||
const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService);
|
||||
|
||||
return roles;
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "view vault Kubernetes auth roles");
|
||||
return getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService);
|
||||
};
|
||||
|
||||
const getVaultKubernetesRoles = async ({
|
||||
@@ -773,44 +583,7 @@ export const externalMigrationServiceFactory = ({
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
orgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can get Kubernetes roles" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "get Kubernetes roles");
|
||||
return getHCVaultKubernetesRoles(namespace, mountPath, connection, gatewayService);
|
||||
};
|
||||
|
||||
@@ -823,44 +596,7 @@ export const externalMigrationServiceFactory = ({
|
||||
namespace: string;
|
||||
mountPath: string;
|
||||
}) => {
|
||||
const { hasRole } = await permissionService.getOrgPermission({
|
||||
scope: OrganizationActionScope.Any,
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
orgId: actor.orgId,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId
|
||||
});
|
||||
|
||||
if (!hasRole(OrgMembershipRole.Admin)) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can get database roles" });
|
||||
}
|
||||
|
||||
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
|
||||
orgId: actor.orgId,
|
||||
namespace
|
||||
});
|
||||
|
||||
if (!vaultConfig) {
|
||||
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
|
||||
}
|
||||
|
||||
if (!vaultConfig.connection) {
|
||||
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
|
||||
}
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId: vaultConfig.orgId,
|
||||
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: null
|
||||
});
|
||||
|
||||
const connection = {
|
||||
...vaultConfig.connection,
|
||||
credentials
|
||||
} as THCVaultConnection;
|
||||
|
||||
const connection = await getVaultConnectionForNamespace(actor, namespace, "get database roles");
|
||||
return getHCVaultDatabaseRoles(namespace, mountPath, connection, gatewayService);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user