|
1 | | -import asyncio |
2 | 1 | import json |
3 | 2 | import logging |
4 | | -import ssl |
| 3 | +import threading |
| 4 | +import time |
5 | 5 | from base64 import b64decode |
6 | 6 | from binascii import Error as Base64DecodeError |
7 | 7 | from datetime import UTC, datetime |
8 | 8 | from gettext import gettext as _ |
9 | 9 | from hashlib import sha256 |
10 | 10 |
|
11 | | -import aiohttp |
12 | 11 | import jq |
| 12 | +import requests |
13 | 13 | from django.conf import settings |
14 | 14 | from django.contrib.postgres.fields import ArrayField, HStoreField |
15 | 15 | from django.db import models |
@@ -54,38 +54,115 @@ class FeatureContentGuardCache(Cache): |
54 | 54 | class FeatureContentGuard(HeaderContentGuard, AutoAddObjPermsMixin): |
55 | 55 | features = ArrayField(models.TextField()) |
56 | 56 |
|
57 | | - def _check_for_feature(self, account_id): |
58 | | - cert_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) |
59 | | - cert_context.load_cert_chain(certfile=settings.FEATURE_SERVICE_API_CERT_PATH) |
| 57 | + _session_lock = threading.Lock() |
| 58 | + _session = None |
60 | 59 |
|
61 | | - account_id_query_param = f"accountId={account_id}" |
62 | | - features_query_param = "&".join(f"features={feature}" for feature in self.features) |
63 | | - feature_service_api_url = f"{settings.FEATURE_SERVICE_API_URL}?{account_id_query_param}&{features_query_param}" |
| 60 | + @classmethod |
| 61 | + def _get_session(cls): |
| 62 | + """ |
| 63 | + Returns a process-wide ``requests.Session`` configured with the client certificate. |
64 | 64 |
|
65 | | - async def fetch_feature(): |
66 | | - async with aiohttp.ClientSession() as session: |
67 | | - async with session.get(feature_service_api_url, ssl=cert_context, raise_for_status=True) as response: |
68 | | - return await response.json() |
| 65 | + The previous implementation built a brand new ``ssl.SSLContext`` (re-reading and |
| 66 | + re-parsing the certificate off disk), a new ``aiohttp.ClientSession``, and a brand |
| 67 | + new asyncio event loop (via ``asyncio.run``) on *every single content request* that |
| 68 | + missed the cache. Reusing a session lets urllib3 keep a warm, pooled TLS connection |
| 69 | + to the Features Service and avoids that repeated setup cost. |
| 70 | + """ |
| 71 | + if cls._session is None: |
| 72 | + with cls._session_lock: |
| 73 | + if cls._session is None: |
| 74 | + session = requests.Session() |
| 75 | + session.cert = settings.FEATURE_SERVICE_API_CERT_PATH |
| 76 | + cls._session = session |
| 77 | + return cls._session |
| 78 | + |
| 79 | + def _check_for_feature(self, account_id): |
| 80 | + session = self._get_session() |
| 81 | + params = [("accountId", account_id), *(("features", feature) for feature in self.features)] |
| 82 | + # permit() runs synchronously on the content app's shared sync-to-async worker thread |
| 83 | + # (pulpcore.content.handler.Handler._permit is invoked via sync_to_async). A slow or |
| 84 | + # hanging call here doesn't just delay this request -- it can stall every other |
| 85 | + # request being served through that same thread, so this must be bounded. |
| 86 | + # |
| 87 | + # This must be a real Python tuple built here, not a settings value passed through |
| 88 | + # as-is: `requests` only splits a *tuple* into (connect, read) timeouts, and Pulp's |
| 89 | + # settings pipeline can silently turn a tuple default into a list, which `requests` |
| 90 | + # then treats as a single (invalid) timeout value and raises a ValueError. |
| 91 | + timeout = (settings.FEATURE_SERVICE_API_CONNECT_TIMEOUT, settings.FEATURE_SERVICE_API_READ_TIMEOUT) |
69 | 92 |
|
70 | 93 | try: |
71 | 94 | _logger.info("[%s] Making a request to feature service API ...", datetime.now(tz=UTC)) |
72 | | - response = asyncio.run(fetch_feature()) |
| 95 | + response = session.get( |
| 96 | + settings.FEATURE_SERVICE_API_URL, |
| 97 | + params=params, |
| 98 | + timeout=timeout, |
| 99 | + ) |
| 100 | + response.raise_for_status() |
73 | 101 | _logger.info("[%s] Got a response from feature service API!", datetime.now(tz=UTC)) |
74 | | - except aiohttp.ClientResponseError as err: |
75 | | - if err.status == 400: |
76 | | - _logger.exception("Failed to request information for a user. BadRequest. URL: %s", err.request_info.url) |
77 | 102 |
|
78 | | - if err.status == 403: |
| 103 | + features_available = {feature["name"] for feature in response.json()["features"]} |
| 104 | + return features_available == set(self.features) |
| 105 | + except requests.Timeout as err: |
| 106 | + _logger.warning( |
| 107 | + "Failed to fetch the Subscription feature information for a user. " |
| 108 | + "The Features Service API did not respond within %s seconds.", |
| 109 | + timeout, |
| 110 | + ) |
| 111 | + raise PermissionError(_("Access denied.")) from err |
| 112 | + except requests.HTTPError as err: |
| 113 | + status_code = err.response.status_code if err.response is not None else None |
| 114 | + if status_code == 400: |
| 115 | + _logger.exception("Failed to request information for a user. BadRequest. URL: %s", err.request.url) |
| 116 | + |
| 117 | + if status_code == 403: |
79 | 118 | _logger.exception( |
80 | 119 | "Failed to request information for a user. " |
81 | 120 | "Permission Denied. Verify if the certificate is still valid." |
82 | 121 | ) |
83 | 122 |
|
84 | 123 | _logger.warning(_("Failed to fetch the Subscription feature information for a user.")) |
85 | 124 | raise PermissionError(_("Access denied.")) from err |
| 125 | + except requests.RequestException as err: |
| 126 | + _logger.warning("Failed to reach the Features Service API: %s", err) |
| 127 | + raise PermissionError(_("Access denied.")) from err |
| 128 | + except Exception as err: |
| 129 | + # `permit()` is only ever allowed to return normally or raise `PermissionError` |
| 130 | + # (pulpcore.content.handler.Handler.auth_cached assumes exactly that contract). |
| 131 | + # Anything else -- a bug here, a misconfigured setting, an unexpected response |
| 132 | + # shape -- would otherwise escape as-is and crash with a confusing, unrelated |
| 133 | + # `UnboundLocalError` in pulpcore's guard-caching `finally` block, masking the |
| 134 | + # real error entirely. Fail closed and log loudly instead. |
| 135 | + _logger.exception("Unexpected error while checking the Features Service.") |
| 136 | + raise PermissionError(_("Access denied.")) from err |
86 | 137 |
|
87 | | - features_available = {feature["name"] for feature in response["features"]} |
88 | | - return features_available == set(self.features) |
| 138 | + @staticmethod |
| 139 | + def _get_cached_result(feature_cache, key): |
| 140 | + """ |
| 141 | + Returns the cached True/False result for `key`, or None if there is no live entry. |
| 142 | +
|
| 143 | + `Cache.set`'s `expires` argument calls Redis' `EXPIRE` on the *entire* hash the |
| 144 | + entry lives under, not on the individual field (Redis hash fields don't carry their |
| 145 | + own TTL). Every account sharing `FeatureContentGuardCache`'s single base key would |
| 146 | + therefore have its cached result's lifetime reset by any *other* account's write, |
| 147 | + which can prematurely evict still-valid entries. Instead, the expiration is embedded |
| 148 | + in the cached value itself and validated on read here, matching the pattern |
| 149 | + `pulpcore.cache.AsyncContentCache` already uses for the same reason. |
| 150 | + """ |
| 151 | + raw = feature_cache.get(key) |
| 152 | + if not raw: |
| 153 | + return None |
| 154 | + try: |
| 155 | + entry = json.loads(raw) |
| 156 | + if entry["expires_at"] < time.time(): |
| 157 | + return None |
| 158 | + return entry["allowed"] |
| 159 | + except (TypeError, ValueError, KeyError): |
| 160 | + return None |
| 161 | + |
| 162 | + @staticmethod |
| 163 | + def _set_cached_result(feature_cache, key, allowed): |
| 164 | + entry = {"allowed": allowed, "expires_at": time.time() + feature_cache.default_expires_ttl} |
| 165 | + feature_cache.set(key, json.dumps(entry), expires=feature_cache.default_expires_ttl) |
89 | 166 |
|
90 | 167 | def permit(self, request): |
91 | 168 | try: |
@@ -113,31 +190,21 @@ def permit(self, request): |
113 | 190 | _logger.exception("Access not allowed - Invalid JSON or Path not found.") |
114 | 191 | raise PermissionError(_("Access denied.")) from exc |
115 | 192 |
|
116 | | - try: |
117 | | - cache_key = f"{header_value}-{','.join(self.features)}" |
118 | | - cache_key_digest = sha256(bytes(cache_key, "utf8")).hexdigest() |
119 | | - feature_cache = FeatureContentGuardCache() |
120 | | - account_allowed = feature_cache.get(cache_key_digest) |
121 | | - |
122 | | - if not account_allowed: |
123 | | - account_allowed = self._check_for_feature(header_value) |
124 | | - serialized_account_allowed = json.dumps(account_allowed) |
125 | | - feature_cache.set( |
126 | | - cache_key_digest, |
127 | | - serialized_account_allowed, |
128 | | - expires=feature_cache.default_expires_ttl, |
129 | | - ) |
130 | | - |
131 | | - if isinstance(account_allowed, bytes): |
132 | | - account_allowed = json.loads(account_allowed) |
133 | | - |
134 | | - if not account_allowed: |
135 | | - _logger.warning("Access not allowed - Features not available for the user.") |
136 | | - raise PermissionError(_("Access denied.")) |
137 | | - |
138 | | - except aiohttp.ClientResponseError as exc: |
139 | | - _logger.warning("Access not allowed - Failed to check for features.") |
140 | | - raise PermissionError(_("Access denied.")) from exc |
| 193 | + cache_key = f"{header_value}-{','.join(self.features)}" |
| 194 | + cache_key_digest = sha256(bytes(cache_key, "utf8")).hexdigest() |
| 195 | + feature_cache = FeatureContentGuardCache() |
| 196 | + account_allowed = self._get_cached_result(feature_cache, cache_key_digest) |
| 197 | + |
| 198 | + if account_allowed is None: |
| 199 | + _logger.debug("Feature cache MISS for key %s", cache_key_digest) |
| 200 | + account_allowed = self._check_for_feature(header_value) |
| 201 | + self._set_cached_result(feature_cache, cache_key_digest, account_allowed) |
| 202 | + else: |
| 203 | + _logger.debug("Feature cache HIT for key %s", cache_key_digest) |
| 204 | + |
| 205 | + if not account_allowed: |
| 206 | + _logger.warning("Access not allowed - Features not available for the user.") |
| 207 | + raise PermissionError(_("Access denied.")) |
141 | 208 |
|
142 | 209 | return |
143 | 210 |
|
|
0 commit comments