Skip to content

Commit c611d4c

Browse files
committed
chore: lint and minor updates
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent f93f5ff commit c611d4c

24 files changed

Lines changed: 1271 additions & 1144 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ test-file:
241241

242242
doctest:
243243
@echo "🧪 Running doctest on all modules..."
244-
@PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest --doctest-modules cpex/ --tb=short --no-cov --disable-warnings
244+
@PYTHONPATH="$(SRC_DIR)" $(VENV_BIN)/pytest --doctest-modules cpex/ --ignore=cpex/templates --tb=short --no-cov --disable-warnings
245245

246246
# =============================================================================
247247
# Documentation (Hugo Book theme — no Python deps required)

cpex/framework/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
PluginResult,
8383
PluginViolation,
8484
TransportType,
85+
UserContext,
8586
)
8687
from cpex.framework.observability import ObservabilityProvider
8788
from cpex.framework.utils import get_attr
@@ -187,4 +188,5 @@ def get_plugin_manager(
187188
"TenantPluginManager",
188189
"ToolPreInvokePayload",
189190
"TransportType",
191+
"UserContext",
190192
]

cpex/framework/models.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import logging
1515
import os
1616
import re
17+
from datetime import datetime
1718
from enum import Enum, StrEnum
1819
from pathlib import Path
1920
from typing import Any, Generic, List, Optional, Self, TypeVar, Union
@@ -1487,6 +1488,56 @@ def plugin_name(self, name: str) -> None:
14871488
self._plugin_name = name
14881489

14891490

1491+
class UserContext(BaseModel):
1492+
"""Authenticated user identity context for propagation to upstream servers and plugins.
1493+
1494+
Attributes:
1495+
user_id: Primary user identifier (typically email).
1496+
email: User email address.
1497+
full_name: User's display name.
1498+
is_admin: Whether the user has admin privileges.
1499+
groups: User's group memberships.
1500+
roles: User's RBAC roles.
1501+
team_id: Current team context (for single-team API tokens).
1502+
teams: All teams the user belongs to.
1503+
department: User's department.
1504+
attributes: Additional user attributes (extensible).
1505+
auth_method: How the user authenticated (bearer, api_key, basic, sso, proxy).
1506+
authenticated_at: When the authentication occurred.
1507+
service_account: Set when a service account is acting on behalf of this user.
1508+
delegation_chain: Chain of delegated identities for audit trail.
1509+
1510+
Examples:
1511+
>>> uc = UserContext(user_id="alice@example.com")
1512+
>>> uc.user_id
1513+
'alice@example.com'
1514+
>>> uc.is_admin
1515+
False
1516+
>>> uc.groups
1517+
[]
1518+
>>> uc2 = UserContext(user_id="bob@example.com", email="bob@example.com", is_admin=True, auth_method="bearer")
1519+
>>> uc2.is_admin
1520+
True
1521+
>>> uc2.auth_method
1522+
'bearer'
1523+
"""
1524+
1525+
user_id: str
1526+
email: Optional[str] = None
1527+
full_name: Optional[str] = None
1528+
is_admin: bool = False
1529+
groups: list[str] = Field(default_factory=list)
1530+
roles: list[str] = Field(default_factory=list)
1531+
team_id: Optional[str] = None
1532+
teams: Optional[list[str]] = None
1533+
department: Optional[str] = None
1534+
attributes: dict[str, Any] = Field(default_factory=dict)
1535+
auth_method: Optional[str] = None
1536+
authenticated_at: Optional[datetime] = None
1537+
service_account: Optional[str] = None
1538+
delegation_chain: list[str] = Field(default_factory=list)
1539+
1540+
14901541
class Config(BaseModel):
14911542
"""Configurations for plugins.
14921543
@@ -1582,6 +1633,7 @@ class GlobalContext(BaseModel):
15821633
Attributes:
15831634
request_id (str): ID of the HTTP request.
15841635
user (str): user ID associated with the request.
1636+
user_context (Optional[UserContext]): structured user identity context.
15851637
tenant_id (str): tenant ID.
15861638
server_id (str): server ID.
15871639
content_type (Optional[str]): Content-Type header from the request.
@@ -1611,6 +1663,7 @@ class GlobalContext(BaseModel):
16111663

16121664
request_id: str
16131665
user: Optional[Union[str, dict[str, Any]]] = None
1666+
user_context: Optional[UserContext] = None
16141667
tenant_id: Optional[str] = None
16151668
server_id: Optional[str] = None
16161669
content_type: Optional[str] = None
@@ -1664,6 +1717,63 @@ class PluginContext(BaseModel):
16641717
global_context: GlobalContext
16651718
metadata: dict[str, Any] = Field(default_factory=dict)
16661719

1720+
@property
1721+
def user_context(self) -> Optional[UserContext]:
1722+
"""Get the authenticated user context.
1723+
1724+
Returns:
1725+
The UserContext if available, None otherwise.
1726+
1727+
Examples:
1728+
>>> gctx = GlobalContext(request_id="req-1")
1729+
>>> ctx = PluginContext(global_context=gctx)
1730+
>>> ctx.user_context is None
1731+
True
1732+
"""
1733+
return self.global_context.user_context
1734+
1735+
@property
1736+
def user_email(self) -> Optional[str]:
1737+
"""Get the authenticated user's email.
1738+
1739+
Falls back to the legacy ``global_context.user`` field when no
1740+
structured UserContext is available.
1741+
1742+
Returns:
1743+
User email string or None.
1744+
1745+
Examples:
1746+
>>> gctx = GlobalContext(request_id="req-1", user="alice@example.com")
1747+
>>> ctx = PluginContext(global_context=gctx)
1748+
>>> ctx.user_email
1749+
'alice@example.com'
1750+
"""
1751+
uc = self.global_context.user_context
1752+
if uc:
1753+
return uc.email
1754+
user = self.global_context.user
1755+
if isinstance(user, str):
1756+
return user
1757+
if isinstance(user, dict):
1758+
return user.get("email")
1759+
return None
1760+
1761+
@property
1762+
def user_groups(self) -> list[str]:
1763+
"""Get the authenticated user's groups.
1764+
1765+
Returns:
1766+
List of group names. Empty if no user context.
1767+
1768+
Examples:
1769+
>>> gctx = GlobalContext(request_id="req-1")
1770+
>>> ctx = PluginContext(global_context=gctx)
1771+
>>> ctx.user_groups
1772+
[]
1773+
"""
1774+
uc = self.global_context.user_context
1775+
return uc.groups if uc else []
1776+
16671777
def get_state(self, key: str, default: Any = None) -> Any:
16681778
"""Get value from shared state.
16691779
@@ -1732,8 +1842,8 @@ class PluginPackageInfo(BaseModel):
17321842
17331843
Examples:
17341844
>>> pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git",
1735-
git_branch_tag_commit="v1.0.0",
1736-
version_constraint=">=1.0.0")
1845+
... git_branch_tag_commit="v1.0.0",
1846+
... version_constraint=">=1.0.0")
17371847
>>> pkg2 = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0")
17381848
"""
17391849

0 commit comments

Comments
 (0)