diff --git a/spp_dci_client/README.rst b/spp_dci_client/README.rst index e3ecc9a56..c27197ec1 100644 --- a/spp_dci_client/README.rst +++ b/spp_dci_client/README.rst @@ -140,6 +140,17 @@ Dependencies Changelog ========= +19.0.2.0.2 +~~~~~~~~~~ + +- fix(security): make the OAuth2 token/header methods private so they + are no longer callable over RPC — a low-privilege internal user can no + longer mint a DCI access token or obtain a Bearer header via + ``get_oauth2_token()`` / ``get_headers()``. Restrict the token cache + fields (``_oauth2_access_token`` / ``_oauth2_token_expires_at``) to + system administrators, and require write access to run a connection + test. + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_dci_client/__manifest__.py b/spp_dci_client/__manifest__.py index 457a40662..84f9afa46 100644 --- a/spp_dci_client/__manifest__.py +++ b/spp_dci_client/__manifest__.py @@ -2,7 +2,7 @@ { "name": "OpenSPP DCI Client", "summary": "Base DCI client infrastructure with OAuth2 and data source management", - "version": "19.0.2.0.1", + "version": "19.0.2.0.2", "category": "OpenSPP/Integration", "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_dci_client/models/data_source.py b/spp_dci_client/models/data_source.py index 71ff54fb5..f5a2bfe17 100644 --- a/spp_dci_client/models/data_source.py +++ b/spp_dci_client/models/data_source.py @@ -176,13 +176,17 @@ class DCIDataSource(models.Model): help="Last connection error message", ) - # OAuth2 token cache fields (transient storage) + # OAuth2 token cache fields (transient storage). Restricted to system + # administrators: the cached access token is a credential and must not be + # readable by ordinary internal users (who have read on this model). _oauth2_access_token = fields.Char( string="Cached Access Token", + groups="base.group_system", help="Cached OAuth2 access token (internal use only)", ) _oauth2_token_expires_at = fields.Datetime( string="Token Expiry", + groups="base.group_system", help="Cached token expiration timestamp (internal use only)", ) @@ -306,14 +310,18 @@ def _check_sender_id(self): if record.auth_type != "none" and not record.our_sender_id: raise ValidationError(_("Sender ID is required for authenticated connections.")) - def clear_oauth2_token_cache(self): + def _clear_oauth2_token_cache(self): """Clear cached OAuth2 token, forcing a fresh token request on next use. - This can be useful when the cached token becomes invalid or when - troubleshooting authentication issues. + Internal (underscore-prefixed) so it is NOT callable over RPC — otherwise + a low-privilege user could force repeated re-minting. It is invoked from + trusted server-side code (e.g. DCIClient on a 401 retry). The cache fields + are admin-restricted, so write via sudo: clearing the cache must work + regardless of the current user's privilege. """ self.ensure_one() - self.write( + # nosemgrep: odoo-sudo-without-context + self.sudo().write( { "_oauth2_access_token": False, "_oauth2_token_expires_at": False, @@ -321,9 +329,13 @@ def clear_oauth2_token_cache(self): ) _logger.info("Cleared OAuth2 token cache for data source: %s", self.code) - def get_oauth2_token(self, force_refresh=False): + def _get_oauth2_token(self, force_refresh=False): """Get or refresh OAuth2 access token. + Internal (underscore-prefixed) so it is NOT callable over RPC: it mints a + token from the administrator-only OAuth2 client secret, so it must only be + reachable from trusted server-side code (e.g. the DCIClient service). + Args: force_refresh: If True, skip cache and fetch a new token @@ -338,24 +350,25 @@ def get_oauth2_token(self, force_refresh=False): if self.auth_type != "oauth2": raise UserError(_("This data source does not use OAuth2 authentication.")) + # sudo(): the OAuth2 client secret and the token cache fields are + # restricted to administrators. This method is internal and only reached + # from trusted server-side code, so reading/writing them via sudo is safe. + sudo_self = self.sudo() # nosemgrep: odoo-sudo-without-context + # Check if cached token is still valid (with 60 second buffer) now = fields.Datetime.now() - if not force_refresh and self._oauth2_access_token and self._oauth2_token_expires_at: - expiry_with_buffer = self._oauth2_token_expires_at - timedelta(seconds=60) + if not force_refresh and sudo_self._oauth2_access_token and sudo_self._oauth2_token_expires_at: + expiry_with_buffer = sudo_self._oauth2_token_expires_at - timedelta(seconds=60) if now < expiry_with_buffer: - _logger.info( - "Using cached OAuth2 token for data source: %s (expires at %s)", - self.code, - self._oauth2_token_expires_at, - ) - return self._oauth2_access_token + # Do not log the token expiry field (it is a credential-adjacent + # cache field); log only the data source code. + _logger.info("Using cached OAuth2 token for data source: %s", self.code) + return sudo_self._oauth2_access_token # Request new token _logger.info("Requesting new OAuth2 token for data source: %s", self.code) try: - # Use sudo() to access OAuth2 credentials which are restricted to administrators - sudo_self = self.sudo() # nosemgrep: odoo-sudo-without-context token_data = { "grant_type": "client_credentials", "client_id": sudo_self.oauth2_client_id, @@ -446,9 +459,13 @@ def get_oauth2_token(self, force_refresh=False): # Show generic user-friendly message raise UserError(_("An unexpected error occurred. Please contact your administrator.")) from e - def get_headers(self, force_refresh_token=False): + def _get_headers(self, force_refresh_token=False): """Get HTTP headers for API requests including authentication. + Internal (underscore-prefixed) so it is NOT callable over RPC: it returns + an Authorization header carrying credentials minted from administrator-only + fields. Call it only from trusted server-side code (e.g. DCIClient). + Args: force_refresh_token: If True, force refresh OAuth2 token (skip cache) @@ -466,7 +483,7 @@ def get_headers(self, force_refresh_token=False): } _logger.debug( - "get_headers() called for data source %s, auth_type=%s, force_refresh=%s", + "_get_headers() called for data source %s, auth_type=%s, force_refresh=%s", self.code, self.auth_type, force_refresh_token, @@ -474,7 +491,7 @@ def get_headers(self, force_refresh_token=False): if self.auth_type == "oauth2": _logger.info("Fetching OAuth2 token for data source %s", self.code) - token = self.get_oauth2_token(force_refresh=force_refresh_token) + token = self._get_oauth2_token(force_refresh=force_refresh_token) headers["Authorization"] = f"Bearer {token}" _logger.info( "Added OAuth2 Authorization header for data source %s (token length: %d)", @@ -482,9 +499,14 @@ def get_headers(self, force_refresh_token=False): len(token) if token else 0, ) elif self.auth_type == "bearer": - if not self.bearer_token: + # bearer_token is admin-restricted (groups=base.group_system); read it + # via sudo so a non-admin internal caller (this method is not + # RPC-exposed) can build the header, matching the OAuth2 branch. + # nosemgrep: odoo-sudo-without-context + bearer_token = self.sudo().bearer_token + if not bearer_token: raise UserError(_("Bearer token is not configured for this data source.")) - headers["Authorization"] = f"Bearer {self.bearer_token}" + headers["Authorization"] = f"Bearer {bearer_token}" return headers @@ -499,10 +521,15 @@ def test_connection(self): """ self.ensure_one() + # Testing a connection mints/uses the data source's administrator-only + # credentials and makes an outbound call, so require management (write) + # access on the record — a read-only user must not trigger it. + self.check_access("write") + _logger.info("Testing connection to data source: %s (%s)", self.name, self.code) try: - headers = self.get_headers() + headers = self._get_headers() # Probe the authenticated ping endpoint. A 200 confirms both # reachability *and* that our credentials are accepted; a 401/403 diff --git a/spp_dci_client/readme/HISTORY.md b/spp_dci_client/readme/HISTORY.md index 4aaf9afef..9d07ae7c7 100644 --- a/spp_dci_client/readme/HISTORY.md +++ b/spp_dci_client/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.2 + +- fix(security): make the OAuth2 token/header methods private so they are no longer callable over RPC — a low-privilege internal user can no longer mint a DCI access token or obtain a Bearer header via `get_oauth2_token()` / `get_headers()`. Restrict the token cache fields (`_oauth2_access_token` / `_oauth2_token_expires_at`) to system administrators, and require write access to run a connection test. + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_dci_client/services/client.py b/spp_dci_client/services/client.py index 522f65f4b..e314493a5 100644 --- a/spp_dci_client/services/client.py +++ b/spp_dci_client/services/client.py @@ -956,8 +956,10 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) """ url = f"{self.data_source.base_url.rstrip('/')}{endpoint}" - # Get headers from data source (includes auth) - headers = self.data_source.get_headers() + # Get headers from data source (includes auth). Internal method — the + # DCIClient service is the trusted server-side entry point for outbound + # DCI calls (get_headers/get_oauth2_token are not RPC-exposed). + headers = self.data_source._get_headers() # Track timing and result for outgoing log start_time = time.monotonic() @@ -999,7 +1001,7 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) log_response_data = response.json() except json.JSONDecodeError: log_response_data = None - self.data_source.clear_oauth2_token_cache() + self.data_source._clear_oauth2_token_cache() return self._make_request(endpoint, envelope, _retry_auth=False) # Check for HTTP errors diff --git a/spp_dci_client/static/description/index.html b/spp_dci_client/static/description/index.html index 11e9ca70c..9c7558b5b 100644 --- a/spp_dci_client/static/description/index.html +++ b/spp_dci_client/static/description/index.html @@ -514,6 +514,18 @@

Changelog

+

19.0.2.0.2

+ +
+

19.0.2.0.0