Publish curated Unraid-AppOps-Gateway source from 451646fee806a47e3256731766980a05a262dcdd
This commit is contained in:
1 parent
532f3d9f26
commit
0a82153d95
43 files changed
+17722
-93
No files matched your search
@@ -0,0 +1,599 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import RFC_4122, UUID
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..core.config import settings
|
||||
from ..db.models import AppSetting, Policy
|
||||
from ..schemas.enrollment import MANAGED_DOCKER_APPDATA_ROOT
|
||||
from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1
|
||||
from ..schemas.forge_oidc import FORGE_OIDC_ISSUER, FORGE_OIDC_LAUNCH_URL
|
||||
from ..schemas.forge_secret_delivery import (
|
||||
ForgeSecretDeliveryControllerArtifactV1,
|
||||
ForgeSecretDeliveryResultV1,
|
||||
)
|
||||
from .audit_logger import log_event
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
from .enrollment_plane import EnrollmentPlane
|
||||
from .forge_oidc_provisioning import _forge_oidc_process_lock
|
||||
from .policy_engine import evaluate_policy
|
||||
from .secret_store import get_secret, has_secret, secret_storage_status
|
||||
|
||||
_ACTION = "forge.secrets.prepare"
|
||||
_TARGET = "itworx-forge"
|
||||
_BROKER_SECRET_REF = "FORGE_DEPLOYMENT_BROKER"
|
||||
_FORGE_OIDC_BINDING_REF = "AUTHENTIK_FORGE_OIDC_BINDING"
|
||||
_FORGE_OIDC_CLIENT_SECRET_REF = "ITWORX_FORGE_OIDC_CLIENT_SECRET"
|
||||
_FORGE_READER_BINDING_REF = "AUTHENTIK_FORGE_READER_BINDING"
|
||||
_FORGE_READER_TOKEN_REF = "ITWORX_FORGE_AUTHENTIK_READ_TOKEN"
|
||||
_FLOW = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
|
||||
_PROVIDER_ID = re.compile(r"[1-9][0-9]{0,9}\Z")
|
||||
_UUID = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z"
|
||||
)
|
||||
_IDEMPOTENCY = re.compile(r"[A-Za-z0-9._:-]{8,128}\Z")
|
||||
_CONFIG_KEYS = {
|
||||
"schema",
|
||||
"issuer",
|
||||
"audience",
|
||||
"client_id",
|
||||
"authentik_api_url",
|
||||
"launch_url",
|
||||
"authorization_flow",
|
||||
"invalidation_flow",
|
||||
"authentik_openid_mapping_id",
|
||||
"authentik_email_mapping_id",
|
||||
"authentik_profile_mapping_id",
|
||||
"authentik_provider_id",
|
||||
"authentik_signing_key_id",
|
||||
"private_package_owner",
|
||||
}
|
||||
_RESPONSE_KEYS = {
|
||||
"apiVersion",
|
||||
"target",
|
||||
"status",
|
||||
"profile",
|
||||
"controllerArtifact",
|
||||
"secretFilesReady",
|
||||
"masterKeyBackupVerified",
|
||||
"applyPreflight",
|
||||
}
|
||||
_ARTIFACT_KEYS = {
|
||||
"controllerVersion",
|
||||
"sourceRepository",
|
||||
"sourceRevision",
|
||||
"sourceTree",
|
||||
"artifactSha256",
|
||||
}
|
||||
|
||||
|
||||
def _exact_secret_prepare_policy_allowed(session: Session) -> str | None:
|
||||
rows = session.exec(select(Policy).where(Policy.action == _ACTION)).all()
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
policy = rows[0]
|
||||
if (
|
||||
policy.target != _TARGET
|
||||
or policy.manual != "allowed"
|
||||
or policy.voice != "blocked"
|
||||
or policy.risk != "critical"
|
||||
):
|
||||
return None
|
||||
decision = evaluate_policy(
|
||||
session,
|
||||
action=_ACTION,
|
||||
target=_TARGET,
|
||||
source="manual",
|
||||
)
|
||||
if (
|
||||
decision.get("decision") != "allowed"
|
||||
or decision.get("scope_decision") != "allowed"
|
||||
or decision.get("policy_id") != policy.id
|
||||
):
|
||||
return None
|
||||
return policy.id
|
||||
|
||||
|
||||
def _secret_store_ready() -> bool:
|
||||
try:
|
||||
status = secret_storage_status()
|
||||
except Exception: # noqa: BLE001 - store diagnostics never enter delivery errors.
|
||||
return False
|
||||
return isinstance(status, dict) and status.get("productionReady") is True
|
||||
|
||||
|
||||
def _canonical_uuid(value: Any) -> bool:
|
||||
if not isinstance(value, str) or _UUID.fullmatch(value) is None:
|
||||
return False
|
||||
try:
|
||||
parsed = UUID(value)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return False
|
||||
return str(parsed) == value and parsed.variant == RFC_4122 and parsed.version in range(1, 9)
|
||||
|
||||
|
||||
def _protected_config(context: dict[str, Any]) -> bytes | None:
|
||||
if not isinstance(context, dict) or set(context) != {
|
||||
"applicationId", "providerId", "scopeMappingIds", "groupIds", "signingKeyId"
|
||||
}:
|
||||
return None
|
||||
provider_id = context.get("providerId")
|
||||
signing_key_id = context.get("signingKeyId")
|
||||
mappings = context.get("scopeMappingIds")
|
||||
if (
|
||||
_PROVIDER_ID.fullmatch(provider_id or "") is None
|
||||
or not _canonical_uuid(signing_key_id)
|
||||
or not isinstance(mappings, dict)
|
||||
or set(mappings) != {"openid", "email", "profile"}
|
||||
or any(not _canonical_uuid(item) for item in mappings.values())
|
||||
or len(set(mappings.values())) != 3
|
||||
):
|
||||
return None
|
||||
if not _canonical_uuid(context.get("applicationId")):
|
||||
return None
|
||||
owner = _protected_forge_private_package_owner()
|
||||
if owner is None:
|
||||
return None
|
||||
authorization_flow = settings.apis.get("AUTHENTIK_OIDC_AUTHORIZATION_FLOW")
|
||||
invalidation_flow = settings.apis.get("AUTHENTIK_OIDC_INVALIDATION_FLOW")
|
||||
if (
|
||||
not isinstance(authorization_flow, str)
|
||||
or _FLOW.fullmatch(authorization_flow) is None
|
||||
or not isinstance(invalidation_flow, str)
|
||||
or _FLOW.fullmatch(invalidation_flow) is None
|
||||
):
|
||||
return None
|
||||
values = {
|
||||
"schema": "itworx.forge-first-install-config/v4",
|
||||
"issuer": FORGE_OIDC_ISSUER,
|
||||
"audience": "itworx-forge",
|
||||
"client_id": "itworx-forge",
|
||||
"authentik_api_url": "https://auth.example-host.com",
|
||||
"launch_url": FORGE_OIDC_LAUNCH_URL,
|
||||
"authorization_flow": authorization_flow,
|
||||
"invalidation_flow": invalidation_flow,
|
||||
"authentik_openid_mapping_id": mappings["openid"],
|
||||
"authentik_email_mapping_id": mappings["email"],
|
||||
"authentik_profile_mapping_id": mappings["profile"],
|
||||
"authentik_provider_id": provider_id,
|
||||
"authentik_signing_key_id": signing_key_id,
|
||||
"private_package_owner": owner,
|
||||
}
|
||||
if set(values) != _CONFIG_KEYS or any(
|
||||
not isinstance(value, str) or not value or len(value) > 512
|
||||
for value in values.values()
|
||||
):
|
||||
return None
|
||||
encoded = json.dumps(values, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return encoded if len(encoded) <= 8192 else None
|
||||
|
||||
|
||||
def _checked_controller_response(
|
||||
value: object,
|
||||
*,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
) -> ForgeSecretDeliveryControllerArtifactV1 | None:
|
||||
if not isinstance(value, dict) or set(value) != _RESPONSE_KEYS:
|
||||
return None
|
||||
if (
|
||||
value.get("apiVersion") != "itworx.forge-secret-delivery/v1"
|
||||
or value.get("target") != _TARGET
|
||||
or value.get("status") != "bundle_ready"
|
||||
or value.get("profile") != profile.model_dump(mode="json")
|
||||
or type(value.get("secretFilesReady")) is not int
|
||||
or value.get("secretFilesReady") != 7
|
||||
or value.get("masterKeyBackupVerified") is not True
|
||||
or value.get("applyPreflight") != "not_evaluated"
|
||||
):
|
||||
return None
|
||||
artifact = value.get("controllerArtifact")
|
||||
if not isinstance(artifact, dict) or set(artifact) != _ARTIFACT_KEYS:
|
||||
return None
|
||||
try:
|
||||
return ForgeSecretDeliveryControllerArtifactV1.model_validate(artifact)
|
||||
except Exception: # noqa: BLE001 - controller response data is untrusted.
|
||||
return None
|
||||
|
||||
|
||||
def _result(
|
||||
*,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
status: str,
|
||||
message: str,
|
||||
artifact: ForgeSecretDeliveryControllerArtifactV1 | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ready = status == "bundle_ready"
|
||||
payload = ForgeSecretDeliveryResultV1(
|
||||
success=ready,
|
||||
status=status,
|
||||
message=message,
|
||||
target=_TARGET,
|
||||
revision=profile.revision,
|
||||
controllerArtifact=artifact,
|
||||
secretFilesReady=7 if ready else None,
|
||||
masterKeyBackupVerified=True if ready else None,
|
||||
applyPreflight="not_evaluated" if ready else None,
|
||||
)
|
||||
return payload.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
*,
|
||||
identity: str,
|
||||
decision: str,
|
||||
result: dict[str, Any],
|
||||
policy_id: str | None,
|
||||
idempotency_key: str,
|
||||
) -> bool:
|
||||
try:
|
||||
artifact = result.get("controllerArtifact")
|
||||
log_event(
|
||||
session,
|
||||
source="manual",
|
||||
identity=identity,
|
||||
action_id=_ACTION,
|
||||
target=_TARGET,
|
||||
target_type="forge_secret_delivery",
|
||||
risk="critical",
|
||||
policy_decision=decision,
|
||||
reason=result["message"],
|
||||
result=result["status"],
|
||||
after_state={
|
||||
"status": result["status"],
|
||||
"revision": result["revision"],
|
||||
"secretFilesReady": result.get("secretFilesReady"),
|
||||
"masterKeyBackupVerified": result.get("masterKeyBackupVerified"),
|
||||
"applyPreflight": result.get("applyPreflight"),
|
||||
"controllerArtifact": artifact,
|
||||
},
|
||||
redacted_metadata={
|
||||
"policyId": policy_id,
|
||||
"revision": result["revision"],
|
||||
"idempotencyKeySha256": hashlib.sha256(idempotency_key.encode("ascii")).hexdigest(),
|
||||
"secretRefs": [
|
||||
_BROKER_SECRET_REF,
|
||||
_FORGE_OIDC_CLIENT_SECRET_REF,
|
||||
_FORGE_READER_TOKEN_REF,
|
||||
],
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - an audit exception may carry held secrets.
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception: # noqa: BLE001 - keep DB error detail out of the response.
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _protected_forge_private_package_owner() -> str | None:
|
||||
from .forge_bootstrap_apply import (
|
||||
_protected_forge_private_package_owner as protected_owner,
|
||||
)
|
||||
|
||||
return protected_owner()
|
||||
|
||||
|
||||
def _fixed_authentik_connector(session: Session) -> AuthentikConnector | None:
|
||||
"""Use only already-loaded, fixed Authentik runtime configuration.
|
||||
|
||||
The generic setup loader also migrates legacy plaintext connector secrets.
|
||||
Secret delivery must not trigger that write as a side effect, so this path
|
||||
verifies the existing runtime against protected/read-only inputs instead.
|
||||
"""
|
||||
try:
|
||||
legacy_secret = session.get(AppSetting, "connector:authentik:secret")
|
||||
if legacy_secret is not None:
|
||||
if legacy_secret.is_secret is not True or not isinstance(legacy_secret.value_json, dict):
|
||||
return None
|
||||
legacy_value = legacy_secret.value_json.get("value")
|
||||
if isinstance(legacy_value, str) and legacy_value.strip():
|
||||
return None
|
||||
if legacy_secret.value_json not in (
|
||||
{},
|
||||
{"secretRef": "AUTHENTIK_TOKEN", "hasSecret": True},
|
||||
{"secretRef": "AUTHENTIK_TOKEN", "hasSecret": True, "migrated": True},
|
||||
):
|
||||
return None
|
||||
|
||||
connector_config = session.get(AppSetting, "connector:authentik:config")
|
||||
if connector_config is not None:
|
||||
if connector_config.is_secret is not False or not isinstance(connector_config.value_json, dict):
|
||||
return None
|
||||
if not set(connector_config.value_json).issubset(
|
||||
{"url", "verifyTls", "outpostUrl", "expectEmbeddedOutpost"}
|
||||
):
|
||||
return None
|
||||
|
||||
connector = AuthentikConnector()
|
||||
config = connector._config()
|
||||
if (
|
||||
config is None
|
||||
or config.url.rstrip("/") != "https://auth.example-host.com"
|
||||
or config.verify_tls is not True
|
||||
or not isinstance(config.token, str)
|
||||
or not config.token
|
||||
):
|
||||
return None
|
||||
|
||||
stored_admin_token = get_secret(session, "AUTHENTIK_TOKEN", touch=False)
|
||||
if not isinstance(stored_admin_token, str) or not stored_admin_token:
|
||||
return None
|
||||
normalized = stored_admin_token.strip()
|
||||
if normalized.lower().startswith("bearer "):
|
||||
normalized = normalized[7:].strip()
|
||||
if not hmac.compare_digest(normalized, config.token):
|
||||
return None
|
||||
|
||||
if connector_config is not None:
|
||||
stored_config = connector_config.value_json
|
||||
runtime_values = {
|
||||
"url": config.url,
|
||||
"verifyTls": config.verify_tls,
|
||||
"outpostUrl": config.outpost_url,
|
||||
"expectEmbeddedOutpost": config.expect_embedded_outpost,
|
||||
}
|
||||
if any(stored_config.get(key) != runtime_values[key] for key in stored_config):
|
||||
return None
|
||||
return connector
|
||||
except Exception: # noqa: BLE001 - config/store failures remain a fixed denial.
|
||||
return None
|
||||
|
||||
|
||||
def prepare_forge_secret_delivery(
|
||||
session: Session,
|
||||
*,
|
||||
profile: ForgeBootstrapPreflightV1,
|
||||
idempotency_key: str,
|
||||
actor_identity: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare only the fixed Forge credential bundle through the trusted broker."""
|
||||
identity = actor_identity.strip() if isinstance(actor_identity, str) else ""
|
||||
request_key = idempotency_key.strip() if isinstance(idempotency_key, str) else ""
|
||||
if not identity:
|
||||
return _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_operator_identity_required",
|
||||
)
|
||||
if _IDEMPOTENCY.fullmatch(request_key) is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_idempotency_key_invalid",
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="blocked",
|
||||
result=outcome,
|
||||
policy_id=None,
|
||||
idempotency_key="invalid-key",
|
||||
)
|
||||
return outcome
|
||||
|
||||
policy_id = _exact_secret_prepare_policy_allowed(session)
|
||||
if policy_id is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_exact_target_policy_required",
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="blocked",
|
||||
result=outcome,
|
||||
policy_id=None,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
return outcome
|
||||
|
||||
if not _secret_store_ready():
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_store_not_production_ready",
|
||||
)
|
||||
audit_ok = _audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="allowed",
|
||||
result=outcome,
|
||||
policy_id=policy_id,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
return outcome if audit_ok else _result(
|
||||
profile=profile, status="failed", message="forge_secret_audit_persist_failed"
|
||||
)
|
||||
|
||||
attempted_delivery = False
|
||||
try:
|
||||
with _forge_oidc_process_lock():
|
||||
if _exact_secret_prepare_policy_allowed(session) != policy_id:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_exact_target_policy_required",
|
||||
)
|
||||
elif not _secret_store_ready():
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_store_not_production_ready",
|
||||
)
|
||||
else:
|
||||
trust = EnrollmentPlane.get_trust_config(session)
|
||||
if (
|
||||
trust is None
|
||||
or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT
|
||||
or trust.managed_appdata_backup_acknowledged is not True
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_controller_trust_required",
|
||||
)
|
||||
elif not all(
|
||||
has_secret(session, reference)
|
||||
for reference in (
|
||||
_FORGE_OIDC_BINDING_REF,
|
||||
_FORGE_OIDC_CLIENT_SECRET_REF,
|
||||
_FORGE_READER_BINDING_REF,
|
||||
_FORGE_READER_TOKEN_REF,
|
||||
)
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_verified_reader_required",
|
||||
)
|
||||
else:
|
||||
from .forge_bootstrap_apply import _controller_secret
|
||||
from .forge_controller_reader import (
|
||||
_ReaderFailure,
|
||||
_verify_forge_delivery_inputs,
|
||||
)
|
||||
|
||||
broker_secret = _controller_secret(session)
|
||||
if not isinstance(broker_secret, str) or not broker_secret:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_controller_trust_required",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
connector = _fixed_authentik_connector(session)
|
||||
if connector is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_authentik_configuration_required",
|
||||
)
|
||||
connector = None
|
||||
if connector is None:
|
||||
verified = None
|
||||
else:
|
||||
verified = _verify_forge_delivery_inputs(session, connector)
|
||||
if verified is None:
|
||||
pass
|
||||
else:
|
||||
config_bytes = _protected_config(verified.context)
|
||||
if config_bytes is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="conflict",
|
||||
message="forge_secret_protected_config_invalid",
|
||||
)
|
||||
elif (
|
||||
verified.oidc_client_secret in config_bytes
|
||||
or verified.authentik_read_token in config_bytes
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="conflict",
|
||||
message="forge_secret_config_contains_secret",
|
||||
)
|
||||
else:
|
||||
attempted_delivery = True
|
||||
broker_result = DeploymentBrokerClient().forge_secret_delivery(
|
||||
profile,
|
||||
config_bytes=config_bytes,
|
||||
oidc_client_secret=verified.oidc_client_secret,
|
||||
authentik_read_token=verified.authentik_read_token,
|
||||
secret=broker_secret,
|
||||
)
|
||||
if (
|
||||
isinstance(broker_result, dict)
|
||||
and broker_result.get("status") in {"missing_config", "missing_secret"}
|
||||
):
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked",
|
||||
message="forge_secret_controller_delivery_unavailable",
|
||||
)
|
||||
attempted_delivery = False
|
||||
elif (
|
||||
isinstance(broker_result, dict)
|
||||
and broker_result.get("status") == "response"
|
||||
):
|
||||
artifact = _checked_controller_response(
|
||||
broker_result.get("response"),
|
||||
profile=profile,
|
||||
)
|
||||
if artifact is None:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown",
|
||||
message="forge_secret_controller_outcome_unknown",
|
||||
)
|
||||
else:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="bundle_ready",
|
||||
message="forge_secret_bundle_ready",
|
||||
artifact=artifact,
|
||||
)
|
||||
else:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown",
|
||||
message="forge_secret_controller_outcome_unknown",
|
||||
)
|
||||
except _ReaderFailure as exc:
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="blocked" if exc.status == "blocked" else "conflict",
|
||||
message="forge_secret_reader_proof_invalid",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never surface connector or held input details.
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status=("outcome_unknown" if attempted_delivery else "failed"),
|
||||
message=(
|
||||
"forge_secret_controller_outcome_unknown"
|
||||
if attempted_delivery
|
||||
else "forge_secret_input_verification_failed"
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - lock and DB errors are fixed safe codes.
|
||||
outcome = _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown" if attempted_delivery else "failed",
|
||||
message=(
|
||||
"forge_secret_controller_outcome_unknown"
|
||||
if attempted_delivery
|
||||
else "forge_secret_preparation_unavailable"
|
||||
),
|
||||
)
|
||||
|
||||
audit_ok = _audit(
|
||||
session,
|
||||
identity=identity,
|
||||
decision="allowed",
|
||||
result=outcome,
|
||||
policy_id=policy_id,
|
||||
idempotency_key=request_key,
|
||||
)
|
||||
if not audit_ok:
|
||||
if attempted_delivery or outcome["status"] == "bundle_ready":
|
||||
return _result(
|
||||
profile=profile,
|
||||
status="outcome_unknown",
|
||||
message="forge_secret_delivery_audit_persist_failed",
|
||||
)
|
||||
return _result(
|
||||
profile=profile,
|
||||
status="failed",
|
||||
message="forge_secret_audit_persist_failed",
|
||||
)
|
||||
return outcome
|
||||
Reference in new issue
Block a user