From 79b74eca4eb0e79e6b8a242e3b818c17608cdc35 Mon Sep 17 00:00:00 2001 From: Ignacio Van Droogenbroeck <64545348+xe-nvdk@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:57:01 -0600 Subject: [PATCH] fix(http): raise a distinct exception per Arc refusal on the database listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc's three database listing endpoints carried no authentication at all. They now require read permission and, where the server restricts reads per database, a grant covering the database being listed. handle_response_error mapped both 401 and 403 onto ArcAuthenticationError, so a caller could not tell "re-authenticate" from "this token lacks a permission" — and 403 is not one condition either. Arc answers it for a token with no read permission, for a token whose grants do not cover the database asked for, for a licence-gated feature, and for its own permission data being unreadable, distinguishing them only by message. ArcAuthenticationError now means 401 only. Added: - ArcPermissionError: the token does not carry the permission the endpoint requires. - ArcScopedAccessError: Arc's per-database denial. The token may read, but not what was asked for. Carries the refused database on .database — empty or "*" when the call asked for every database, since Arc refuses databases.list() for a tenant-scoped token rather than returning a filtered list, matching SHOW DATABASES. A normal state, not a fault to retry. - ArcPermissionDataUnavailableError: Arc could not load the token's grants and failed closed. A server-side fault that says nothing about the token, so it is checked before the scoped shape and never reported as one. All subclass ArcPermissionError, which subclasses ArcAuthenticationError, so existing except blocks that caught 403 as an authentication failure keep working. Matching is by stable message fragment and covers both the read middleware's wording and the handler gates', so a change to the permission word cannot reclassify one refusal as another; an unrecognised 403 falls back to ArcPermissionError. Docstrings on list/get/list_measurements, sync and async, document which exception each refusal raises. --- CHANGELOG.md | 16 + README.md | 29 +- src/arc_client/__init__.py | 6 + src/arc_client/exceptions.py | 60 ++++ src/arc_client/http/base.py | 113 ++++++- src/arc_client/management/async_databases.py | 40 ++- src/arc_client/management/databases.py | 49 ++- tests/unit/http/test_databases_permissions.py | 280 ++++++++++++++++++ 8 files changed, 584 insertions(+), 9 deletions(-) create mode 100644 tests/unit/http/test_databases_permissions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb68f4..447d024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ plus bug fixes and the first real HTTP-layer test coverage. `retry_backoff`. - New exceptions: `ArcNotSupportedError` (501) and `ArcLicenseError` (403 for a missing enterprise-license feature; subclass of `ArcAuthenticationError`). +- `ArcPermissionError` (403: the token lacks the permission the endpoint + requires), `ArcScopedAccessError` (403 from Arc's per-database check: the + token may read, but not the database asked for) and + `ArcPermissionDataUnavailableError` (403 because Arc could not load the + token's grants and failed closed — a server-side fault, not a statement + about the token). `ArcScopedAccessError` carries the refused database on + `.database`, empty or `"*"` when the call asked for every database. The + latter two subclass `ArcPermissionError`, which subclasses + `ArcAuthenticationError`, so existing `except` blocks keep working. - Real HTTP round-trip test coverage (via `pytest-httpx`) for headers, error mapping, compression on the wire, retries, msgpack decode, imports, databases, and enterprise wrappers — sync and async. Opt-in live integration tests under @@ -46,6 +55,13 @@ plus bug fixes and the first real HTTP-layer test coverage. from the payload's magic bytes. - `User-Agent` is now derived from the package version instead of a hardcoded string, and the package version is a single source of truth read by the build. +- HTTP 403 no longer collapses onto `ArcAuthenticationError`, which now means + only 401. The database listing endpoints need read permission and, where the + server restricts reads per database, a grant for the database — and Arc + refuses `databases.list()` for a tenant-scoped token rather than returning a + filtered list, matching `SHOW DATABASES`. That refusal is an expected state, + not a fault to retry: it raises `ArcScopedAccessError` naming the database, + while a token with no read permission raises `ArcPermissionError`. ### Fixed diff --git a/README.md b/README.md index 8a4b6da..961996e 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,30 @@ measurements = client.databases.list_measurements("metrics") client.databases.delete("metrics", confirm=True) ``` +The three read methods need a token with read permission. On a server that +restricts reads per database they also need a grant for the database in +question, and `list()` needs one covering every database — Arc refuses rather +than returning a filtered list, the same way `SHOW DATABASES` does: + +```python +from arc_client.exceptions import ArcScopedAccessError + +try: + databases = client.databases.list().databases +except ArcScopedAccessError: + # Expected for a tenant-scoped token: it cannot enumerate every database, + # so ask for the one it is granted instead of retrying. + measurements = client.databases.list_measurements("metrics") +``` + +`ArcScopedAccessError.database` carries the database the call was refused for +(empty or `"*"` when the call asked for every database). A token with no read +permission at all raises `ArcPermissionError` instead; Arc failing to load the +token's grants raises `ArcPermissionDataUnavailableError`, a server-side fault +rather than a statement about the token; and a database that does not exist +still raises `ArcNotFoundError` — "you may not read it" and "it is not there" +stay apart. + ## Querying Data ### JSON Response @@ -445,7 +469,10 @@ the codec from the payload's magic bytes, so no `Content-Encoding` header is sen from arc_client.exceptions import ( ArcError, # Base exception ArcConnectionError, # Connection failures - ArcAuthenticationError,# Auth failures (401/403) + ArcAuthenticationError,# Token missing, invalid or expired (401) + ArcPermissionError, # Token lacks the required permission (403) + ArcScopedAccessError, # Token may read, but not this database (403) + ArcPermissionDataUnavailableError, # Arc could not load the grants (403) ArcLicenseError, # Enterprise feature not licensed (403) ArcQueryError, # Query execution errors ArcIngestionError, # Write failures diff --git a/src/arc_client/__init__.py b/src/arc_client/__init__.py index 11e22c3..fcbf45b 100644 --- a/src/arc_client/__init__.py +++ b/src/arc_client/__init__.py @@ -17,8 +17,11 @@ ArcLicenseError, ArcNotFoundError, ArcNotSupportedError, + ArcPermissionDataUnavailableError, + ArcPermissionError, ArcQueryError, ArcRateLimitError, + ArcScopedAccessError, ArcServerError, ArcValidationError, ) @@ -42,6 +45,9 @@ "ArcServerError", "ArcNotSupportedError", "ArcLicenseError", + "ArcPermissionError", + "ArcPermissionDataUnavailableError", + "ArcScopedAccessError", # Version "__version__", ] diff --git a/src/arc_client/exceptions.py b/src/arc_client/exceptions.py index a7b36bf..a0244ff 100644 --- a/src/arc_client/exceptions.py +++ b/src/arc_client/exceptions.py @@ -79,3 +79,63 @@ class ArcLicenseError(ArcAuthenticationError): """ pass + + +class ArcPermissionError(ArcAuthenticationError): + """HTTP 403: the token does not carry the permission the endpoint needs. + + Distinct from :class:`ArcAuthenticationError`, which now means only HTTP + 401 — the token is missing, invalid or expired. A 401 is fixed by + re-authenticating; a 403 is not, and needs a token with different + permissions. + + Subclasses :class:`ArcAuthenticationError` so existing ``except`` blocks + that caught 403 as an authentication failure keep working. + """ + + pass + + +class ArcPermissionDataUnavailableError(ArcPermissionError): + """HTTP 403 because Arc could not load the token's grants. + + Arc fails closed when its permission data cannot be read, which arrives + as a 403 like a real refusal. Nothing about the token or the database + caused it, so it is raised separately: retrying with a different database + or a different token will not help, and the Arc server log is where the + cause is. + """ + + pass + + +class ArcScopedAccessError(ArcPermissionError): + """HTTP 403 from Arc's per-database permission check. + + The token may read, but not the database the call asked about. Raised by + the database listing endpoints: + + * :meth:`arc_client.management.databases.DatabaseClient.list` — Arc + refuses to list every database for a token restricted to particular + ones rather than returning a filtered list, matching ``SHOW + DATABASES``. ``database`` is then ``"*"``. + * :meth:`~arc_client.management.databases.DatabaseClient.get` and + :meth:`~arc_client.management.databases.DatabaseClient.list_measurements` + — the token holds no grant for the database named, which ``database`` + carries. + + This is a normal state for a tenant-scoped token, not a transient + failure: an identical retry is refused identically, and the way forward + is to name a database the token is granted. It is deliberately separate + from :class:`ArcPermissionError` so "you are scoped, name your database" + and "this token cannot read at all" can be handled differently. + + Attributes: + database: The database the call was refused for, or ``"*"`` when it + asked for every database. ``None`` if Arc's message did not name + one. + """ + + def __init__(self, message: str, database: Optional[str] = None) -> None: + super().__init__(message) + self.database = database diff --git a/src/arc_client/http/base.py b/src/arc_client/http/base.py index d92e359..ffa588b 100644 --- a/src/arc_client/http/base.py +++ b/src/arc_client/http/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from typing import Any, Optional import httpx @@ -15,7 +16,10 @@ ArcLicenseError, ArcNotFoundError, ArcNotSupportedError, + ArcPermissionDataUnavailableError, + ArcPermissionError, ArcRateLimitError, + ArcScopedAccessError, ArcServerError, ) @@ -38,8 +42,83 @@ def build_headers( return headers +# Arc sends HTTP 403 for three different things and distinguishes them only +# by message, so the message is what these read. The shapes its read +# middleware and handler gates produce: +# +# no permission for read on database 'x' -> scoped +# access denied: no read permission for database 'x' -> scoped +# permission data unavailable -> grants unreadable +# token does not have 'read' permission -> no read permission +# Permission denied: read required -> no read permission +# +# Only the first two are resolved by naming a different database, so they +# must not collapse onto one exception. The patterns match the stable parts +# of each message rather than the whole string, so a change to the permission +# word or the surrounding punctuation cannot silently reclassify one as +# another, and the database name is captured where Arc sends one. +_SCOPED_DENIAL_RES = ( + # The read middleware on the listing routes. The name is empty on the + # list-everything route, which sends no database. + re.compile( + r"^no permission for \w+ on database '(?P[^']*)'", + re.IGNORECASE, + ), + # The handler gates' own wording. + re.compile( + r"^access denied: no \w+ permission for database '(?P[^']*)'", + re.IGNORECASE, + ), +) + +# Arc could not load the token's grants and refused rather than guess. A 403 +# like the others, but it says nothing about the token or the database, so it +# must not be reported as a permission problem. Checked first. +_PERMISSION_DATA_UNAVAILABLE = "permission data unavailable" + + +def _scoped_denial_database(message: object) -> Optional[str]: + """Return the database named by a per-database denial, else None. + + An empty capture is returned as ``""`` — Arc sends an empty name on the + list-everything route — so callers must test against ``None``, not + truthiness, to tell "not a scoped denial" from "scoped, no name". + """ + if not isinstance(message, str): + return None + text = message.strip() + for pattern in _SCOPED_DENIAL_RES: + match = pattern.match(text) + if match is not None: + return match.group("database") + return None + + +def _is_permission_data_unavailable(message: object) -> bool: + """True if Arc failed closed because it could not load the grants.""" + return isinstance(message, str) and _PERMISSION_DATA_UNAVAILABLE in message.lower() + + def handle_response_error(response: httpx.Response) -> None: - """Handle HTTP error responses and raise appropriate exceptions.""" + """Handle HTTP error responses and raise appropriate exceptions. + + Raises: + ArcAuthenticationError: HTTP 401 — the token is missing, invalid or + expired. Re-authenticate. + ArcLicenseError: HTTP 403 naming an enterprise license. + ArcScopedAccessError: HTTP 403 from Arc's per-database permission + check — the token may read, but not the database asked for. A + subclass of ``ArcPermissionError``; carries ``database``. + ArcPermissionDataUnavailableError: HTTP 403 because Arc could not + load the token's grants and failed closed. A server-side fault, + not a statement about the token. + ArcPermissionError: any other HTTP 403 — the token does not carry the + permission the endpoint requires. + ArcNotFoundError: HTTP 404. + ArcRateLimitError: HTTP 429. + ArcNotSupportedError: HTTP 501. + ArcServerError: HTTP 5xx and any other non-2xx status. + """ if response.is_success: return @@ -58,7 +137,37 @@ def handle_response_error(response: httpx.Response) -> None: # ArcAuthenticationError, so plain permission-denied handling still works). if isinstance(message, str) and "enterprise license" in message.lower(): raise ArcLicenseError(f"Enterprise license required: {message}") - raise ArcAuthenticationError(f"Permission denied: {message}") + # Checked before the scoped shape: Arc failing to read its own + # permission data is a server fault, and reporting it as "your token + # is scoped" would send the caller after a database that was never + # the problem. + if _is_permission_data_unavailable(message): + raise ArcPermissionDataUnavailableError( + f"Arc could not read its own permission data and refused the request " + f"rather than guessing. This is a server-side fault, not a problem " + f"with this token — check the Arc server log. ({message})" + ) + # Arc's per-database check refuses a token that may read but holds no + # grant for the database asked for. Naming a database it is granted is + # the only thing that resolves it, so it gets its own exception rather + # than being reported as "this token cannot read". + database = _scoped_denial_database(message) + if database is not None: + if database in ("", "*"): + detail = ( + "this token is scoped to specific databases, so Arc will not list " + "them all. Name one with get() or list_measurements()." + ) + else: + detail = ( + f"this token is scoped to specific databases and holds no read " + f"grant for {database!r}. Name a database it is granted." + ) + raise ArcScopedAccessError( + f"Access denied: {detail} ({message})", + database=database, + ) + raise ArcPermissionError(f"Permission denied: {message}") elif status_code == 404: raise ArcNotFoundError(f"Resource not found: {message}") elif status_code == 429: diff --git a/src/arc_client/management/async_databases.py b/src/arc_client/management/async_databases.py index db61aa2..c3c6bb0 100644 --- a/src/arc_client/management/async_databases.py +++ b/src/arc_client/management/async_databases.py @@ -23,7 +23,21 @@ def __init__(self, http: AsyncHTTPClient, config: ClientConfig) -> None: self._config = config async def list(self) -> DatabaseListResponse: - """List all databases.""" + """List all databases. + + Needs a token with read permission. Where the server restricts reads + per database, it also needs a grant covering every database: Arc + refuses this call for a token scoped to particular databases rather + than returning a filtered list, the same way ``SHOW DATABASES`` does. + + Raises: + ArcScopedAccessError: The token is scoped to specific databases + (``database`` is ``"*"``). Expected for a tenant-scoped + token — name a database with :meth:`get` or + :meth:`list_measurements` instead of retrying. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + """ try: response = await self._http.get("/api/v1/databases") return DatabaseListResponse.model_validate(response.json()) @@ -33,7 +47,18 @@ async def list(self) -> DatabaseListResponse: raise ArcError(f"Failed to list databases: {e}") from e async def get(self, name: str) -> DatabaseInfo: - """Get a single database by name.""" + """Get a single database by name. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). It may be able to read others. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. Kept separate from the + permission errors: "you may not read it" and "it is not + there" are different answers. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = await self._http.get(f"/api/v1/databases/{name}") @@ -44,7 +69,16 @@ async def get(self, name: str) -> DatabaseInfo: raise ArcError(f"Failed to get database {name!r}: {e}") from e async def list_measurements(self, name: str) -> MeasurementListResponse: - """List measurements within a database.""" + """List measurements within a database. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). Ask for a database it is granted. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = await self._http.get(f"/api/v1/databases/{name}/measurements") diff --git a/src/arc_client/management/databases.py b/src/arc_client/management/databases.py index b89ccb5..262b138 100644 --- a/src/arc_client/management/databases.py +++ b/src/arc_client/management/databases.py @@ -21,6 +21,15 @@ class DatabaseClient: Create and delete require an admin token. Delete additionally requires the server to have delete operations enabled and an explicit ``confirm=True``. + The three read methods need a token with read permission. On a server that + restricts reads per database they also need a grant for the database in + question, and :meth:`list` needs one covering every database — Arc refuses + rather than returning a filtered list, matching ``SHOW DATABASES``. That + refusal raises :class:`~arc_client.exceptions.ArcScopedAccessError`, which + is a normal state for a tenant-scoped token rather than a fault to retry; + a token with no read permission at all raises + :class:`~arc_client.exceptions.ArcPermissionError`. + Example: >>> client.databases.create("metrics") >>> for db in client.databases.list().databases: @@ -32,7 +41,21 @@ def __init__(self, http: SyncHTTPClient, config: ClientConfig) -> None: self._config = config def list(self) -> DatabaseListResponse: - """List all databases.""" + """List all databases. + + Needs a token with read permission. Where the server restricts reads + per database, it also needs a grant covering every database: Arc + refuses this call for a token scoped to particular databases rather + than returning a filtered list, the same way ``SHOW DATABASES`` does. + + Raises: + ArcScopedAccessError: The token is scoped to specific databases + (``database`` is ``"*"``). Expected for a tenant-scoped + token — name a database with :meth:`get` or + :meth:`list_measurements` instead of retrying. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + """ try: response = self._http.get("/api/v1/databases") return DatabaseListResponse.model_validate(response.json()) @@ -42,7 +65,18 @@ def list(self) -> DatabaseListResponse: raise ArcError(f"Failed to list databases: {e}") from e def get(self, name: str) -> DatabaseInfo: - """Get a single database by name.""" + """Get a single database by name. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). It may be able to read others. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. Kept separate from the + permission errors: "you may not read it" and "it is not + there" are different answers. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = self._http.get(f"/api/v1/databases/{name}") @@ -53,7 +87,16 @@ def get(self, name: str) -> DatabaseInfo: raise ArcError(f"Failed to get database {name!r}: {e}") from e def list_measurements(self, name: str) -> MeasurementListResponse: - """List measurements within a database.""" + """List measurements within a database. + + Raises: + ArcScopedAccessError: The token holds no read grant for ``name`` + (carried on ``database``). Ask for a database it is granted. + ArcPermissionError: The token carries no read permission. + ArcAuthenticationError: The token is missing, invalid or expired. + ArcNotFoundError: No such database. + ArcValidationError: The name is not a valid database name. + """ validate_name(name, "database") try: response = self._http.get(f"/api/v1/databases/{name}/measurements") diff --git a/tests/unit/http/test_databases_permissions.py b/tests/unit/http/test_databases_permissions.py new file mode 100644 index 0000000..e373437 --- /dev/null +++ b/tests/unit/http/test_databases_permissions.py @@ -0,0 +1,280 @@ +"""Arc's answers for the database listing endpoints. + +The three ``GET /api/v1/databases*`` routes require read permission and, on a +server that restricts reads per database, a grant for the database asked for. +A 401, the two different 403s and a 404 are four different answers and must +reach callers as four different exceptions. +""" + +from __future__ import annotations + +import pytest +from pytest_httpx import HTTPXMock + +from arc_client import ArcClient, AsyncArcClient +from arc_client.exceptions import ( + ArcAuthenticationError, + ArcError, + ArcNotFoundError, + ArcPermissionDataUnavailableError, + ArcPermissionError, + ArcScopedAccessError, +) + +# The bodies the server actually sends. Every 403 here shares a status code +# and differs only in message, so the message is the whole discriminator. +NO_TOKEN = {"success": False, "error": "Authentication required"} +BAD_TOKEN = {"success": False, "error": "Invalid or expired token"} +# Coarse refusals: the token carries no read permission at all. +NO_READ_PERMISSION = {"success": False, "error": "token does not have 'read' permission"} +OSS_NO_READ = {"success": False, "error": "Permission denied: read required"} +# The read middleware's RBAC denial, which is what these routes return in +# practice. The name is empty on the list-everything route, which sends none. +SCOPED_ALL = {"success": False, "error": "no permission for read on database ''"} +SCOPED_NAMED = {"success": False, "error": "no permission for read on database 'metrics'"} +# The handler gates' own wording, reachable when they check a different +# database/measurement pair than the middleware did. +GATE_SCOPED_ALL = {"error": "access denied: no read permission for database '*'"} +GATE_SCOPED_NAMED = {"error": "access denied: no read permission for database 'metrics'"} +# Arc could not load the token's grants and refused rather than guess. +PERMISSION_DATA_UNAVAILABLE = {"success": False, "error": "permission data unavailable"} +NOT_FOUND = {"error": "Database 'ghost' not found"} + + +@pytest.fixture +def client() -> ArcClient: + c = ArcClient(host="h", token="t", database="db", max_retries=0) + yield c + c.close() + + +# --- Exception hierarchy ----------------------------------------------------- + + +def test_scoped_access_is_a_permission_error() -> None: + """Callers that only care about "refused" can catch one class.""" + assert issubclass(ArcScopedAccessError, ArcPermissionError) + assert issubclass(ArcPermissionError, ArcAuthenticationError) + assert issubclass(ArcPermissionError, ArcError) + + +def test_scoped_access_carries_the_database() -> None: + error = ArcScopedAccessError("refused", database="metrics") + assert error.database == "metrics" + assert ArcScopedAccessError("refused").database is None + + +# --- list() ------------------------------------------------------------------ + + +def test_list_raises_scoped_for_a_tenant_scoped_token( + httpx_mock: HTTPXMock, client: ArcClient +) -> None: + """Arc refuses the list-everything route rather than filtering it.""" + httpx_mock.add_response(status_code=403, json=SCOPED_ALL) + + with pytest.raises(ArcScopedAccessError) as excinfo: + client.databases.list() + + # Arc sends no database name on this route, so the advice must be + # "name one", not "you have no grant for ''". + assert excinfo.value.database == "" + message = str(excinfo.value) + assert "scoped to specific databases" in message + assert "will not list them all" in message + assert "''" not in message.replace("database ''", "") + + +def test_list_raises_permission_error_without_read( + httpx_mock: HTTPXMock, client: ArcClient +) -> None: + """A token with no read permission is a different problem.""" + httpx_mock.add_response(status_code=403, json=NO_READ_PERMISSION) + + with pytest.raises(ArcPermissionError) as excinfo: + client.databases.list() + + assert not isinstance(excinfo.value, ArcScopedAccessError) + assert "Permission denied" in str(excinfo.value) + + +@pytest.mark.parametrize("body", [NO_TOKEN, BAD_TOKEN]) +def test_list_raises_authentication_error_on_401( + httpx_mock: HTTPXMock, client: ArcClient, body: dict +) -> None: + httpx_mock.add_response(status_code=401, json=body) + + with pytest.raises(ArcAuthenticationError) as excinfo: + client.databases.list() + + # 401 must not surface as a permission problem: re-authenticating fixes it, + # a different token's permissions do not. + assert not isinstance(excinfo.value, ArcPermissionError) + assert "Authentication failed" in str(excinfo.value) + + +def test_list_success_unchanged(httpx_mock: HTTPXMock, client: ArcClient) -> None: + httpx_mock.add_response( + json={"databases": [{"name": "metrics", "measurement_count": 2}], "count": 1} + ) + + result = client.databases.list() + + assert result.count == 1 + assert result.databases[0].name == "metrics" + + +# --- get() and list_measurements() ------------------------------------------ + + +@pytest.mark.parametrize("method", ["get", "list_measurements"]) +def test_per_database_routes_raise_scoped_and_name_the_database( + httpx_mock: HTTPXMock, client: ArcClient, method: str +) -> None: + httpx_mock.add_response(status_code=403, json=SCOPED_NAMED) + + with pytest.raises(ArcScopedAccessError) as excinfo: + getattr(client.databases, method)("metrics") + + assert excinfo.value.database == "metrics" + assert "metrics" in str(excinfo.value) + + +@pytest.mark.parametrize("method", ["get", "list_measurements"]) +def test_per_database_routes_keep_404_separate( + httpx_mock: HTTPXMock, client: ArcClient, method: str +) -> None: + """ "You may not read it" and "it is not there" are different answers.""" + httpx_mock.add_response(status_code=404, json=NOT_FOUND) + + with pytest.raises(ArcNotFoundError) as excinfo: + getattr(client.databases, method)("ghost") + + assert not isinstance(excinfo.value, ArcPermissionError) + assert "not found" in str(excinfo.value) + + +@pytest.mark.parametrize("method", ["get", "list_measurements"]) +def test_per_database_routes_raise_permission_error_without_read( + httpx_mock: HTTPXMock, client: ArcClient, method: str +) -> None: + httpx_mock.add_response(status_code=403, json=NO_READ_PERMISSION) + + with pytest.raises(ArcPermissionError) as excinfo: + getattr(client.databases, method)("metrics") + + assert not isinstance(excinfo.value, ArcScopedAccessError) + + +# --- 403s that are not the per-database denial ------------------------------- + + +def test_unknown_403_body_is_not_reported_as_scoped( + httpx_mock: HTTPXMock, client: ArcClient +) -> None: + """Telling a caller to name a database it already named is worse than + naming the permission, so only the recognised shape classifies as scoped.""" + httpx_mock.add_response(status_code=403, json={"error": "Delete operations are disabled."}) + + with pytest.raises(ArcPermissionError) as excinfo: + client.databases.get("metrics") + + assert not isinstance(excinfo.value, ArcScopedAccessError) + + +def test_license_403_still_maps_to_license_error(httpx_mock: HTTPXMock, client: ArcClient) -> None: + """The pre-existing enterprise-license 403 keeps its own exception.""" + from arc_client.exceptions import ArcLicenseError + + httpx_mock.add_response( + status_code=403, + json={"error": "This feature requires an enterprise license"}, + ) + + with pytest.raises(ArcLicenseError): + client.databases.list() + + +# --- async twin -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_list_raises_scoped(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(status_code=403, json=SCOPED_ALL) + + c = AsyncArcClient(host="h", token="t", database="db", max_retries=0) + try: + with pytest.raises(ArcScopedAccessError) as excinfo: + await c.databases.list() + assert excinfo.value.database == "" + finally: + await c.close() + + +@pytest.mark.asyncio +async def test_async_list_measurements_raises_scoped_and_names_database( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response(status_code=403, json=SCOPED_NAMED) + + c = AsyncArcClient(host="h", token="t", database="db", max_retries=0) + try: + with pytest.raises(ArcScopedAccessError) as excinfo: + await c.databases.list_measurements("metrics") + assert excinfo.value.database == "metrics" + finally: + await c.close() + + +# --- the full 403 message matrix --------------------------------------------- + + +@pytest.mark.parametrize( + ("body", "expected", "expected_database"), + [ + # Arc's read middleware, which is what these routes use. + (SCOPED_NAMED, ArcScopedAccessError, "metrics"), + (SCOPED_ALL, ArcScopedAccessError, ""), + # The handler gates' own wording. + (GATE_SCOPED_NAMED, ArcScopedAccessError, "metrics"), + (GATE_SCOPED_ALL, ArcScopedAccessError, "*"), + # The token carries no read permission at all — a different problem, + # and naming a database will not help. + (NO_READ_PERMISSION, ArcPermissionError, None), + (OSS_NO_READ, ArcPermissionError, None), + # Arc could not read its own grants: a server fault, not the token's. + (PERMISSION_DATA_UNAVAILABLE, ArcPermissionDataUnavailableError, None), + # An unrecognised 403 falls back to the permission message, which is + # safe to say about any refusal. + ({"error": "Delete operations are disabled."}, ArcPermissionError, None), + ], +) +def test_every_forbidden_body_maps_to_its_own_exception( + httpx_mock: HTTPXMock, + client: ArcClient, + body: dict, + expected: type, + expected_database: object, +) -> None: + httpx_mock.add_response(status_code=403, json=body) + + with pytest.raises(expected) as excinfo: + client.databases.get("metrics") + + # The mapping must be exact, not just "a subclass of something". + assert type(excinfo.value) is expected + if expected_database is not None: + assert excinfo.value.database == expected_database + + +def test_unavailable_is_not_reported_as_scoped(httpx_mock: HTTPXMock, client: ArcClient) -> None: + """A broken permission store must not send the caller after a database.""" + httpx_mock.add_response(status_code=403, json=PERMISSION_DATA_UNAVAILABLE) + + with pytest.raises(ArcPermissionDataUnavailableError) as excinfo: + client.databases.list() + + assert not isinstance(excinfo.value, ArcScopedAccessError) + message = str(excinfo.value) + assert "server-side fault" in message + assert "scoped" not in message