Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions PYEGERIA_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,16 @@ Sorted newest-first by `ISSUE-#` (2026-10-12). ISSUE-91 has two entries

---

### ISSUE-167: The 401 auto-retry re-authenticated a caller-supplied bearer token as the client's own user

**Layer:** Pyegeria · **Status:** fixed on branch `fix/external-token-no-auth-retry`, for 6.2.15 · **Found:** 2026-10-28, by the egeria-workspaces-fs portal session (Lineage Explorer queries made by a user logged in as Peter ran as erinoverview).

**What happened:** on a bare 401, `BaseServerClient._async_make_request` minted a new token with `self.user_id` / `self.user_pwd` and retried. It did that whatever token was in the headers, including one the caller had set with `set_bearer_token()` for a different user. So an expired or invalid token quietly became a token for the client's user, and the request succeeded as that user with no error. `user_pwd` falls back to `settings.User_Profile.user_pwd`, so a client built without a password was exposed too. Repro on 6.2.14: `MyProfile(view, url, "erinoverview", "secret")`, then `set_bearer_token(<expired token>)`, then `get_my_profile()` returned Erin's profile. `exec_report_spec(token=..., user=..., user_pass=...)` had the same exposure.

**Fix:** the client records the user it minted its current token for (`_token_minted_for`; `None` for a token from the constructor or `set_bearer_token`). The retry runs only when there is no token or the token was minted for `self.user_id`. A caller-supplied token that expires now raises `PyegeriaUnauthorizedException`. `EgeriaTech` and `Egeria` pass the minting user to their sub-clients, including ones created later, so their own tokens still renew. Tests: `tests/micro-tests/test_external_token_no_auth_retry.py`.

---

### ISSUE-166: Security annotation properties are Java primitives, so an unset CVSS score, exploit/patch flag or posture count reads back as 0 / false

**Layer:** Egeria Server (type model / properties classes) · **Status:** fixed (Egeria 6.3, odpi/egeria#9389); pyegeria adapted in #498, released in 6.2.13 · **Found:** 2026-10-26, while adding the 6.3 security annotation types to pyegeria (odpi/egeria-workspaces#702 hand-off, step 3; PR #495).
Expand Down
29 changes: 27 additions & 2 deletions pyegeria/core/_base_server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def __init__(
self.page_size = page_size or max_paging_size
self.token_src = token_src
self.token = token
# The user a bearer token was minted for by this client, or None for a token
# supplied by the caller (constructor or set_bearer_token), whose owner we can't know.
# The 401 auto-retry only re-mints for a token this client minted for its own user;
# re-minting over someone else's token would silently switch the request's identity.
self._token_minted_for = None
self.local_qualifier = local_qualifier or settings.User_Profile.egeria_local_qualifier
self.organization_name = organization_name or settings.Environment.organization_name

Expand Down Expand Up @@ -281,6 +286,7 @@ async def _async_create_egeria_bearer_token(
self.token_src = "Egeria"
self.headers["Authorization"] = f"Bearer {token}"
self.text_headers["Authorization"] = f"Bearer {token}"
self._token_minted_for = user_id
return token

def create_egeria_bearer_token(
Expand Down Expand Up @@ -389,11 +395,25 @@ def set_bearer_token(self, token: str, source: str="Egeria") -> None:
------
PyegeriaInvalidParameterException
If the token is invalid.

Notes
-----
A token set here is never silently replaced: when it expires, requests raise
PyegeriaUnauthorizedException rather than re-authenticating as this client's
user_id, which may not be the user the token belongs to.
"""
validate_name(token)
self.token_src = source
self.headers["Authorization"] = f"Bearer {token}"
self.text_headers["Authorization"] = f"Bearer {token}"
self._token_minted_for = None

def _owns_token(self) -> bool:
"""True if there is no bearer token, or this client minted it for its own user_id."""
return (
"Authorization" not in self.headers
or self._token_minted_for == self.user_id
)

def get_token(self) -> str:
"""Retrieve and return the current bearer token.
Expand Down Expand Up @@ -617,8 +637,13 @@ async def _async_make_request(
# feasible fix, since the response carries no signal to distinguish
# "expired" from "wrong credentials"/"insufficient permission".
# _retry_on_auth=False on the recursive call guarantees at most one
# retry ever happens.
if status_code_from_error == 401 and _retry_on_auth and self.user_pwd:
# retry ever happens. Only when the current token (if any) was minted
# here for self.user_id - a caller-supplied token may belong to another
# user, and re-minting would quietly run the request as self.user_id.
if (
status_code_from_error == 401 and _retry_on_auth and self.user_pwd
and self._owns_token()
):
try:
new_token = await self._async_create_egeria_bearer_token()
except Exception:
Expand Down
11 changes: 10 additions & 1 deletion pyegeria/egeria_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def __init__(
self.user_id = user_id or settings.User_Profile.user_name
self.user_pwd = user_pwd or settings.User_Profile.user_pwd
self.token = token
# See BaseServerClient._token_minted_for: None means caller-supplied.
self._token_minted_for = None
self.timeout = timeout

self._subclient_map = {
Expand Down Expand Up @@ -150,6 +152,7 @@ def _get_subclient(self, attr_name: str):
self.token,
timeout=self.timeout,
)
self._instantiated_clients[attr_name]._token_minted_for = self._token_minted_for
return self._instantiated_clients[attr_name]

def __getattr__(self, name):
Expand All @@ -170,13 +173,19 @@ def create_egeria_bearer_token(self, user_id: str = None, user_pwd: str = None):
# Use server_ops as a reliable helper for token generation
helper = self._get_subclient("server_ops")
token_val = helper.create_egeria_bearer_token(user_id, user_pwd)
self.set_bearer_token(token_val)
self._share_token(token_val, user_id or helper.user_id)
return token_val

def set_bearer_token(self, token: str) -> None:
self._share_token(token, None)

def _share_token(self, token: str, minted_for: str | None) -> None:
"""Install a token on this client and every active sub-client, keeping who it was minted for."""
self.token = token
self._token_minted_for = minted_for
for sub in self._instantiated_clients.values():
sub.set_bearer_token(token)
sub._token_minted_for = minted_for

def get_token(self) -> str:
if self.token: return self.token
Expand Down
13 changes: 11 additions & 2 deletions pyegeria/egeria_tech_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def __init__(
self.user_id = user_id or settings.User_Profile.user_name
self.user_pwd = user_pwd or settings.User_Profile.user_pwd
self.token = token
# See BaseServerClient._token_minted_for: None means caller-supplied.
self._token_minted_for = None
self.timeout = timeout

# Mapping of attribute names to their classes for lazy loading
Expand Down Expand Up @@ -166,6 +168,7 @@ def _get_subclient(self, attr_name: str):
self.token,
timeout=self.timeout,
)
self._instantiated_clients[attr_name]._token_minted_for = self._token_minted_for
return self._instantiated_clients[attr_name]

def __getattr__(self, name):
Expand Down Expand Up @@ -196,22 +199,28 @@ async def _async_create_egeria_bearer_token(
# Use a reliable sub-client to generate the token (e.g., collections)
helper = self._get_subclient("collections")
token_val = await helper._async_create_egeria_bearer_token(user_id, password, new_password)
self.set_bearer_token(token_val)
self._share_token(token_val, user_id or helper.user_id)
return token_val

def create_egeria_bearer_token(self, user_id: str = None, password: str = None, new_password: str = None) -> str:
"""Create token and synchronize it across all instantiated sub-clients."""
# Use a reliable sub-client to generate the token (e.g., collections)
helper = self._get_subclient("collections")
token_val = helper.create_egeria_bearer_token(user_id, password, new_password)
self.set_bearer_token(token_val)
self._share_token(token_val, user_id or helper.user_id)
return token_val

def set_bearer_token(self, token: str) -> None:
"""Update token for the tech client and all active sub-clients."""
self._share_token(token, None)

def _share_token(self, token: str, minted_for: str | None) -> None:
"""Install a token on this client and every active sub-client, keeping who it was minted for."""
self.token = token
self._token_minted_for = minted_for
for sub in self._instantiated_clients.values():
sub.set_bearer_token(token)
sub._token_minted_for = minted_for

def get_token(self) -> str:
"""Retrieve the current token from state or an active sub-client."""
Expand Down
1 change: 1 addition & 0 deletions pyegeria/omvs/automated_curation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2540,6 +2540,7 @@ def _governance_engine_client(self):
client = GovernanceEngine(self.view_server, self.platform_url, self.user_id, self.user_pwd,
token=self.token, timeout=getattr(self, "timeout", None),
legacy_fallback=True)
client._token_minted_for = self._token_minted_for
self._gov_engine_client = client
return client

Expand Down
1 change: 1 addition & 0 deletions pyegeria/omvs/classification_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12301,6 +12301,7 @@ def _collection_client(self):
if client is None:
client = CollectionManager(self.view_server, self.platform_url, self.user_id, self.user_pwd,
token=self.token, timeout=getattr(self, "timeout", None))
client._token_minted_for = self._token_minted_for
self._collection_mgr = client
return client

Expand Down
1 change: 1 addition & 0 deletions pyegeria/omvs/data_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,7 @@ def _asset_maker_client(self):
if client is None:
client = AssetMaker(self.view_server, self.platform_url, self.user_id, self.user_pwd,
token=self.token, timeout=getattr(self, "timeout", None))
client._token_minted_for = self._token_minted_for
self._asset_maker = client
return client

Expand Down
4 changes: 3 additions & 1 deletion pyegeria/view/format_set_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,15 @@ def _resolve_action_target_client(egeria_client: EgeriaTech, client_class: type)
return egeria_client._get_subclient(attr_name)

# Fallback for non-standard clients outside EgeriaTech's map.
return client_class(
client = client_class(
egeria_client.view_server,
egeria_client.platform_url,
user_id=egeria_client.user_id,
user_pwd=egeria_client.user_pwd,
token=egeria_client.token,
)
client._token_minted_for = getattr(egeria_client, "_token_minted_for", None)
return client


def _normalize_report_params(params: Dict[str, Any], action_mode: str = "generic") -> Dict[str, Any]:
Expand Down
127 changes: 127 additions & 0 deletions tests/micro-tests/test_external_token_no_auth_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""
The 401 auto-retry in BaseServerClient._async_make_request re-mints a token as the client's own
user_id. It must only do that for a token the client minted for that user (or when it has no
token). A token supplied by the caller via set_bearer_token may belong to another user: re-minting
over it silently ran the request as the client's user (a portal session logged in as one user ran
Lineage Explorer queries as erinoverview).
"""
import json

import httpx
import pytest

import pyegeria.core._base_platform_client as platform_module
import pyegeria.core._base_server_client as server_module
from pyegeria.core._base_platform_client import BasePlatformClient
from pyegeria.core._base_server_client import BaseServerClient
from pyegeria.core._exceptions import PyegeriaUnauthorizedException
from pyegeria.egeria_tech_client import EgeriaTech

PLATFORM = "https://localhost:9443"
VIEW_SERVER = "qs-view-server"
ENDPOINT = f"{PLATFORM}/servers/{VIEW_SERVER}/api/open-metadata/test"


class FakeEgeria:
"""Mints numbered tokens per user; only the latest token per user is accepted."""

def __init__(self):
self.minted = [] # user ids, in mint order
self.valid = set()
self.served_as = [] # user id behind each accepted data request

def handler(self, request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/api/token"):
user = json.loads(request.content)["userId"]
self.minted.append(user)
token = f"{user}-{len(self.minted)}"
self.valid = {t for t in self.valid if not t.startswith(f"{user}-")} | {token}
return httpx.Response(200, text=token)
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
if token not in self.valid:
return httpx.Response(401, text="Unauthorized")
self.served_as.append(token.rsplit("-", 1)[0])
return httpx.Response(200, json={"class": "VoidResponse", "relatedHTTPCode": 200})


@pytest.fixture
def egeria(monkeypatch):
fake = FakeEgeria()
# The constructors check the connection; that is not under test here.
monkeypatch.setattr(BaseServerClient, "check_connection", lambda self: "test")
monkeypatch.setattr(BasePlatformClient, "check_connection", lambda self: "test")
real_client = httpx.AsyncClient

def client(*args, **kwargs):
kwargs.pop("verify", None)
return real_client(transport=httpx.MockTransport(fake.handler))

monkeypatch.setattr(platform_module, "AsyncClient", client)
monkeypatch.setattr(server_module, "AsyncClient", client)
return fake


def _client():
return BaseServerClient(VIEW_SERVER, PLATFORM, user_id="erinoverview", user_pwd="secret")


async def test_external_token_is_not_replaced(egeria):
client = _client()
client.set_bearer_token("peterprofile-expired")
with pytest.raises(PyegeriaUnauthorizedException):
await client._async_make_request("POST", ENDPOINT, {})
assert egeria.minted == []
assert egeria.served_as == []


async def test_own_expired_token_is_renewed(egeria):
client = _client()
await client._async_create_egeria_bearer_token()
egeria.valid.clear() # the token expires
await client._async_make_request("POST", ENDPOINT, {})
assert egeria.minted == ["erinoverview", "erinoverview"]
assert egeria.served_as == ["erinoverview"]


async def test_no_token_mints_one(egeria):
client = _client()
await client._async_make_request("POST", ENDPOINT, {})
assert egeria.minted == ["erinoverview"]
assert egeria.served_as == ["erinoverview"]


async def test_token_minted_for_another_user_is_not_replaced(egeria):
client = _client()
await client._async_create_egeria_bearer_token("peterprofile", "peters-password")
egeria.valid.clear()
with pytest.raises(PyegeriaUnauthorizedException):
await client._async_make_request("POST", ENDPOINT, {})
assert egeria.minted == ["peterprofile"]


async def test_set_bearer_token_after_minting_disables_retry(egeria):
client = _client()
await client._async_create_egeria_bearer_token()
client.set_bearer_token("peterprofile-expired")
with pytest.raises(PyegeriaUnauthorizedException):
await client._async_make_request("POST", ENDPOINT, {})
assert egeria.minted == ["erinoverview"]


async def test_egeria_tech_minted_token_renews_in_sub_clients(egeria):
tech = EgeriaTech(VIEW_SERVER, PLATFORM, "erinoverview", "secret")
await tech._async_create_egeria_bearer_token()
egeria.valid.clear()
# A sub-client created after minting inherits the token and its owner.
await tech._get_subclient("glossary_manager")._async_make_request("POST", ENDPOINT, {})
assert egeria.served_as == ["erinoverview"]


async def test_egeria_tech_external_token_is_not_replaced(egeria):
tech = EgeriaTech(VIEW_SERVER, PLATFORM, "erinoverview", "secret")
tech._get_subclient("collections")
tech.set_bearer_token("peterprofile-expired")
for name in ("collections", "glossary_manager"): # existing and newly created sub-clients
with pytest.raises(PyegeriaUnauthorizedException):
await tech._get_subclient(name)._async_make_request("POST", ENDPOINT, {})
assert egeria.minted == []
Loading