Skip to content
Open
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
179 changes: 177 additions & 2 deletions nuxeo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import atexit
import json
import logging
from typing import Any, Dict, Optional, Tuple, Type, Union
from typing import Any, Dict, List, Optional, Tuple, Type, Union
from warnings import warn

import requests
Expand Down Expand Up @@ -88,6 +88,39 @@ class NuxeoClient(object):
:param kwargs: kwargs passed to :func:`NuxeoClient.request`
"""

# Cache mapping username -> generated UID.
# Populated lazily on first encounter via resolve_uid / resolve_username.
userid_mapper = {} # type: Dict[str, str]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Shared class-level userid_mapper cache may cause cross-client leakage and threading issues.

Because userid_mapper is a mutable class attribute, all NuxeoClient instances share the same mapping. This risks leaking username→UID mappings across clients (e.g., with different auth contexts) and is not thread-safe. Prefer an instance attribute initialized in __init__ so each client maintains its own cache.

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userid_mapper is declared as a mutable class attribute ({}), which makes the cache shared across all NuxeoClient instances (and threads) and will also be flagged by flake8-mutable. This can cause cross-client data leakage and hard-to-debug behavior. Make this an instance attribute initialized in __init__ (e.g., self.userid_mapper = {}) and keep the class attribute either removed or set to None/a typing-only declaration.

Suggested change
userid_mapper = {} # type: Dict[str, str]
userid_mapper = None # type: Optional[Dict[str, str]]

Copilot uses AI. Check for mistakes.

# --- Known param keys that carry a username value in REQUESTS ---
# These keys, when found inside the ``params`` dict sent to the server
# (either as URL query parameters or inside the automation payload),
# contain a username string that must be translated to a generated UID.
_USERNAME_REQUEST_KEYS = frozenset({
"username", # Document.AddPermission, User.Invite, …
"user", # Document.RemovePermission, …
"users", # bulk user references
"userId", # Task queries, …
"actors", # Task delegate / reassign (may be a list)
"delegatedActors", # Task delegate (may be a list)
})

# --- Known response keys whose value is a username / list of usernames ---
# After the server starts returning generated UIDs in these fields, we
# translate them back to usernames so callers (e.g. Drive) see no change.
_USERNAME_RESPONSE_KEYS = frozenset({
"lockOwner",
"dc:lastContributor",
"dc:creator",
"dc:contributors", # list of usernames
"lastContributor", # filesystem-item style
"principalName", # audit entries
"actors", # task actors (list, prefixed "user:…")
"initiator", # workflow initiator
"author", # comment author
"creator", # some simplified payloads
})

def __init__(
self,
auth=None, # type: AuthType
Expand Down Expand Up @@ -168,6 +201,134 @@ def disable_retry(self):
self._session.mount("https://", TCPKeepAliveHTTPSAdapter())
self._session.mount("http://", HTTPAdapter())

# ------------------------------------------------------------------
# Username ↔ Generated-UID mapping
# ------------------------------------------------------------------

def _fetch_uid_for_username(self, username):
# type: (str) -> str
"""
Call the server to obtain the generated UID for *username*.

.. note::
Dummy placeholder — returns a deterministic fake UID.
Replace the body once the actual server endpoint is known.
"""
dummy_uid = f"uid-{username}"
logger.debug("_fetch_uid_for_username(%r) -> %r [DUMMY]", username, dummy_uid)
return dummy_uid

def _fetch_username_for_uid(self, uid):
# type: (str) -> str
"""
Call the server to obtain the username for a generated *uid*.

.. note::
Dummy placeholder — strips the ``uid-`` prefix.
Replace the body once the actual server endpoint is known.
"""
dummy_username = uid.replace("uid-", "", 1) if uid.startswith("uid-") else uid
logger.debug("_fetch_username_for_uid(%r) -> %r [DUMMY]", uid, dummy_username)
return dummy_username

def resolve_uid(self, username):
# type: (str) -> str
"""Return the generated UID for *username*, using cache first."""
if username in self.userid_mapper:
return self.userid_mapper[username]

uid = self._fetch_uid_for_username(username)
self.userid_mapper[username] = uid
return uid

def resolve_username(self, uid):
# type: (str) -> str
"""Return the username for a generated *uid*, using reverse cache first."""
for username, mapped_uid in self.userid_mapper.items():
if mapped_uid == uid:
return username

username = self._fetch_username_for_uid(uid)
Comment on lines +237 to +251
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_username() performs a linear scan over userid_mapper on every lookup. With response translation enabled, this can become an O(n²) hot path when translating large payloads (audit entries, document lists, etc.). Consider maintaining a second dict for reverse lookups (uid→username) or storing both directions when populating the cache so lookups are O(1).

Suggested change
if username in self.userid_mapper:
return self.userid_mapper[username]
uid = self._fetch_uid_for_username(username)
self.userid_mapper[username] = uid
return uid
def resolve_username(self, uid):
# type: (str) -> str
"""Return the username for a generated *uid*, using reverse cache first."""
for username, mapped_uid in self.userid_mapper.items():
if mapped_uid == uid:
return username
username = self._fetch_username_for_uid(uid)
# Lazily initialize reverse mapping cache if needed.
if not hasattr(self, "_uid_to_username"):
self._uid_to_username = {}
if username in self.userid_mapper:
uid = self.userid_mapper[username]
# Keep reverse cache in sync without overwriting any existing mapping.
self._uid_to_username.setdefault(uid, username)
return uid
uid = self._fetch_uid_for_username(username)
self.userid_mapper[username] = uid
self._uid_to_username[uid] = username
return uid
def resolve_username(self, uid):
# type: (str) -> str
"""Return the username for a generated *uid*, using reverse cache first."""
# Lazily initialize reverse mapping cache if needed.
if not hasattr(self, "_uid_to_username"):
self._uid_to_username = {}
# Fast path: direct lookup in reverse cache (O(1)).
if uid in self._uid_to_username:
return self._uid_to_username[uid]
# Cache miss: fetch from server and update both mappings.
username = self._fetch_username_for_uid(uid)
self._uid_to_username[uid] = username

Copilot uses AI. Check for mistakes.
self.userid_mapper[username] = uid
return username

# ------------------------------------------------------------------
# Request / Response translation helpers
# ------------------------------------------------------------------

def _translate_request_params(self, params):
# type: (Dict[str, Any]) -> Dict[str, Any]
"""
Scan *params* for known username-carrying keys and replace each
username value with its corresponding generated UID.

Returns a **new** dict — the original is never mutated.
"""
if not params:
return params

translated = {}
for key, value in params.items():
if key not in self._USERNAME_REQUEST_KEYS:
translated[key] = value
continue

if isinstance(value, str):
translated[key] = self._translate_single_username_to_uid(value)
elif isinstance(value, list):
translated[key] = [
self._translate_single_username_to_uid(v) if isinstance(v, str) else v
for v in value
]
else:
translated[key] = value
return translated

def _translate_single_username_to_uid(self, value):
# type: (str) -> str
"""Translate a single username string to its UID.

Supports prefixed values like ``"user:john"``."""
if ":" in value:
prefix, name = value.split(":", 1)
return f"{prefix}:{self.resolve_uid(name)}"
return self.resolve_uid(value)

def _translate_response(self, data):
# type: (Any) -> Any
"""
Recursively walk *data* (dict / list) and replace generated UIDs
with usernames in every field whose key is in
``_USERNAME_RESPONSE_KEYS``.

Mutates in-place for efficiency.
"""
if isinstance(data, dict):
for key, value in data.items():
if key in self._USERNAME_RESPONSE_KEYS:
data[key] = self._translate_uid_value_to_username(value)
elif isinstance(value, (dict, list)):
self._translate_response(value)
elif isinstance(data, list):
for item in data:
if isinstance(item, (dict, list)):
self._translate_response(item)
return data

def _translate_uid_value_to_username(self, value):
# type: (Any) -> Any
"""Translate a single UID string (or list of UIDs) back to username(s).

Supports prefixed values like ``"user:uid-john"``."""
if isinstance(value, str):
if ":" in value:
prefix, uid_part = value.split(":", 1)
return f"{prefix}:{self.resolve_username(uid_part)}"
return self.resolve_username(value)
elif isinstance(value, list):
return [self._translate_uid_value_to_username(v) for v in value]
return value

def query(
self,
query, # type: str
Expand Down Expand Up @@ -249,8 +410,12 @@ def request(

kwargs.update(self.client_kwargs)

# --- Translate URL query params (e.g. ?userId=alice) -----------
if "params" in kwargs and isinstance(kwargs["params"], dict):
kwargs["params"] = self._translate_request_params(kwargs["params"])

# Set the default value to `object` to allow someone
# to set `timeout` to `None`.
# to set `default` to `None`.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above the timeout defaulting logic is incorrect (it mentions default instead of timeout). This is misleading when reading the request flow, especially since the real default handling happens a few lines later. Please update the comment to refer to timeout here.

Suggested change
# to set `default` to `None`.
# to set `timeout` to `None`.

Copilot uses AI. Check for mistakes.
if kwargs.get("timeout", object) is object:
kwargs["timeout"] = (TIMEOUT_CONNECT, TIMEOUT_READ)

Expand Down Expand Up @@ -343,6 +508,16 @@ def request(
exc = None
del exc

# --- Wrap response so .json() auto-translates UIDs → usernames ---
if isinstance(resp, requests.Response) and self.userid_mapper is not None:
_original_json = resp.json

def _translated_json(**kw):
data = _original_json(**kw)
return self._translate_response(data)

resp.json = _translated_json # type: ignore[assignment]

return resp

def _check_headers_and_params_format(self, headers, params):
Expand Down
13 changes: 11 additions & 2 deletions nuxeo/operations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# coding: utf-8
import logging
from collections.abc import Sequence
from os import fsync
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union

from requests import Response

Expand All @@ -14,6 +15,8 @@
if TYPE_CHECKING:
from .client import NuxeoClient

logger = logging.getLogger(__name__)
Comment on lines 16 to +18
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger is declared but never used in this module, which will raise F841 local variable 'logger' is assigned to but never used under the repo's flake8 configuration. Please remove the logger definition (and the import logging if nothing else needs it).

Copilot uses AI. Check for mistakes.

# Types allowed for operations parameters
# See https://docs.oracle.com/javase/tutorial/java/nutsandbolts/datatypes.html
# for default values
Expand Down Expand Up @@ -165,6 +168,11 @@ def execute(

command, input_obj, params, context = self.get_attributes(operation, **kwargs)

# ── REQUEST TRANSLATION: username → generated UID ──
# Before param validation and sending, replace any username values
# in known param keys with their corresponding generated UIDs.
params = self.client._translate_request_params(params)

if check_params:
self.check_params(command, params)

Expand Down Expand Up @@ -208,7 +216,8 @@ def execute(

if json:
try:
return resp.json()
result = resp.json()
return result
except ValueError:
pass
return resp.content
Expand Down
Loading
Loading