Skip to content

Commit 0a7499d

Browse files
committed
refactor: make operation_context a structured object with PII validation
Address PR comments: - Replace raw string with OperationContext dataclass (key=value format) - Add regex validation to reject PII (emails, free-form text, passwords) - Rename client kwarg from operation_context to context - Validation happens at OperationContext creation time (fail-fast) - 20 unit tests covering valid formats, PII rejection, and User-Agent header
1 parent dbdeb12 commit 0a7499d

5 files changed

Lines changed: 123 additions & 44 deletions

File tree

src/PowerPlatform/Dataverse/client.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from azure.core.credentials import TokenCredential
1313

1414
from .core._auth import _AuthManager
15-
from .core.config import DataverseConfig
15+
from .core.config import DataverseConfig, OperationContext
1616
from .data._odata import _ODataClient
1717
from .operations.dataframe import DataFrameOperations
1818
from .operations.records import RecordOperations
@@ -44,14 +44,14 @@ class DataverseClient:
4444
:param config: Optional configuration for language, timeouts, and retries.
4545
If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`.
4646
:type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None
47-
:param operation_context: Optional caller-defined context string appended to the
48-
outbound ``User-Agent`` header as a parenthesized comment. Cannot be used
47+
:param context: Optional caller-defined context object appended to the
48+
outbound ``User-Agent`` header for plugin/tool attribution. Cannot be used
4949
together with ``config`` -- pass the context via
5050
:class:`~PowerPlatform.Dataverse.core.config.DataverseConfig` instead.
51-
:type operation_context: :class:`str` or None
51+
:type context: ~PowerPlatform.Dataverse.core.config.OperationContext or None
5252
5353
:raises ValueError: If ``base_url`` is missing or empty after trimming.
54-
:raises ValueError: If both ``config`` and ``operation_context`` are provided.
54+
:raises ValueError: If both ``config`` and ``context`` are provided.
5555
5656
.. note::
5757
The client lazily initializes its internal OData client on first use, allowing lightweight construction without immediate network calls.
@@ -102,21 +102,20 @@ def __init__(
102102
credential: TokenCredential,
103103
config: Optional[DataverseConfig] = None,
104104
*,
105-
operation_context: Optional[str] = None,
105+
context: Optional[OperationContext] = None,
106106
) -> None:
107-
if config is not None and operation_context is not None:
107+
if config is not None and context is not None:
108108
raise ValueError(
109-
"Cannot specify both 'config' and 'operation_context'. "
110-
"Pass operation_context via DataverseConfig instead."
109+
"Cannot specify both 'config' and 'context'. " "Pass operation_context via DataverseConfig instead."
111110
)
112111
self.auth = _AuthManager(credential)
113112
self._base_url = (base_url or "").rstrip("/")
114113
if not self._base_url:
115114
raise ValueError("base_url is required.")
116115
if config is not None:
117116
self._config = config
118-
elif operation_context is not None:
119-
self._config = DataverseConfig(operation_context=operation_context)
117+
elif context is not None:
118+
self._config = DataverseConfig(operation_context=context)
120119
else:
121120
self._config = DataverseConfig.from_env()
122121
self._odata: Optional[_ODataClient] = None

src/PowerPlatform/Dataverse/core/config.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,50 @@
1111

1212
from __future__ import annotations
1313

14+
import re
1415
from dataclasses import dataclass
1516
from typing import TYPE_CHECKING, Optional
1617

1718
if TYPE_CHECKING:
1819
from .log_config import LogConfig
1920

21+
# key=value pairs separated by semicolons.
22+
# Keys: alphanumeric, hyphens, underscores.
23+
# Values: alphanumeric, hyphens, underscores, dots, slashes.
24+
_CONTEXT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$")
25+
26+
27+
@dataclass(frozen=True)
28+
class OperationContext:
29+
"""Caller-defined context appended to outbound ``User-Agent`` headers.
30+
31+
The context string is validated to be semicolon-separated ``key=value`` pairs
32+
(e.g. ``"app=myapp/1.0;agent=claude-code"``). Free-form text, email
33+
addresses, and other potentially sensitive strings are rejected.
34+
35+
:param operation_context: Attribution string in ``key=value;key=value`` format.
36+
:type operation_context: :class:`str`
37+
38+
:raises ValueError: If the string is empty, contains control characters, or
39+
does not match the required ``key=value`` format.
40+
"""
41+
42+
operation_context: str
43+
44+
def __post_init__(self) -> None:
45+
val = self.operation_context
46+
if not val:
47+
raise ValueError("operation_context must not be empty.")
48+
if any(c in val for c in "\r\n\x00"):
49+
raise ValueError("operation_context must not contain CR, LF, or NUL characters.")
50+
if not _CONTEXT_PATTERN.match(val):
51+
raise ValueError(
52+
"operation_context must be semicolon-separated key=value pairs "
53+
"(e.g. 'app=myapp/1.0;agent=claude-code'). "
54+
"Keys and values may contain alphanumerics, hyphens, underscores, "
55+
"dots, and slashes."
56+
)
57+
2058

2159
@dataclass(frozen=True)
2260
class DataverseConfig:
@@ -35,10 +73,10 @@ class DataverseConfig:
3573
When provided, all HTTP requests and responses are logged to timestamped
3674
``.log`` files with automatic redaction of sensitive headers.
3775
:type log_config: ~PowerPlatform.Dataverse.core.log_config.LogConfig or None
38-
:param operation_context: Optional caller-defined context string appended to the
76+
:param operation_context: Optional caller-defined context object appended to the
3977
outbound ``User-Agent`` header as a parenthesized comment. Intended for
40-
plugin/tool attribution (e.g. ``"app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"``).
41-
:type operation_context: :class:`str` or None
78+
plugin/tool attribution.
79+
:type operation_context: ~PowerPlatform.Dataverse.core.config.OperationContext or None
4280
"""
4381

4482
language_code: int = 1033
@@ -50,7 +88,7 @@ class DataverseConfig:
5088

5189
log_config: Optional["LogConfig"] = None
5290

53-
operation_context: Optional[str] = None
91+
operation_context: Optional[OperationContext] = None
5492

5593
@classmethod
5694
def from_env(cls) -> "DataverseConfig":

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,8 @@ def __init__(
206206
session=session,
207207
logger=self._http_logger,
208208
)
209-
ctx = self.config.operation_context
210-
if ctx and any(c in ctx for c in "\r\n\x00"):
211-
raise ValueError("operation_context must not contain CR, LF, or NUL characters.")
212-
self._operation_context = ctx
209+
ctx_obj = self.config.operation_context
210+
self._operation_context = ctx_obj.operation_context if ctx_obj else None
213211
self._logical_to_entityset_cache: dict[str, str] = {}
214212
# Cache: normalized table_schema_name (lowercase) -> primary id attribute (e.g. accountid)
215213
self._logical_primaryid_cache: dict[str, str] = {}

tests/unit/data/test_enum_optionset_payload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, language_code=1033):
2525
self.http_backoff = 0
2626
self.http_timeout = 5
2727
self.log_config = None
28-
self.operation_context = None
28+
self.operation_context = None # None or OperationContext object
2929

3030

3131
def _make_client(lang=1033):

tests/unit/test_operation_context.py

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,51 @@
99
from azure.core.credentials import TokenCredential
1010

1111
from PowerPlatform.Dataverse.client import DataverseClient
12-
from PowerPlatform.Dataverse.core.config import DataverseConfig
12+
from PowerPlatform.Dataverse.core.config import DataverseConfig, OperationContext
1313
from PowerPlatform.Dataverse.data._odata import _ODataClient, _USER_AGENT
1414

1515

16+
class TestOperationContextValidation(unittest.TestCase):
17+
"""Tests for OperationContext format validation and PII rejection."""
18+
19+
def test_valid_single_pair(self):
20+
ctx = OperationContext(operation_context="app=test/1.0")
21+
self.assertEqual(ctx.operation_context, "app=test/1.0")
22+
23+
def test_valid_multiple_pairs(self):
24+
ctx = OperationContext(operation_context="app=test/1.0;skill=dv-data;agent=claude-code")
25+
self.assertEqual(ctx.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code")
26+
27+
def test_valid_with_dots_slashes_hyphens(self):
28+
ctx = OperationContext(operation_context="app=dataverse-skills/1.2.1")
29+
self.assertEqual(ctx.operation_context, "app=dataverse-skills/1.2.1")
30+
31+
def test_reject_empty(self):
32+
with self.assertRaises(ValueError):
33+
OperationContext(operation_context="")
34+
35+
def test_reject_email(self):
36+
with self.assertRaises(ValueError):
37+
OperationContext(operation_context="myname@email.com")
38+
39+
def test_reject_freeform_text(self):
40+
with self.assertRaises(ValueError):
41+
OperationContext(operation_context="my bank password is 1234")
42+
43+
def test_reject_control_chars(self):
44+
for bad in ["has\rnewline", "has\nnewline", "has\x00null"]:
45+
with self.assertRaises(ValueError):
46+
OperationContext(operation_context=bad)
47+
48+
def test_reject_spaces(self):
49+
with self.assertRaises(ValueError):
50+
OperationContext(operation_context="app=my app")
51+
52+
def test_reject_no_equals(self):
53+
with self.assertRaises(ValueError):
54+
OperationContext(operation_context="justaplainstring")
55+
56+
1657
class TestOperationContextConfig(unittest.TestCase):
1758
"""Tests for operation_context on DataverseConfig."""
1859

@@ -21,47 +62,57 @@ def test_default_is_none(self):
2162
self.assertIsNone(config.operation_context)
2263

2364
def test_explicit_value(self):
24-
config = DataverseConfig(operation_context="app=test/1.0;agent=claude-code")
25-
self.assertEqual(config.operation_context, "app=test/1.0;agent=claude-code")
65+
ctx = OperationContext(operation_context="app=test/1.0;agent=claude-code")
66+
config = DataverseConfig(operation_context=ctx)
67+
self.assertEqual(config.operation_context.operation_context, "app=test/1.0;agent=claude-code")
2668

2769
def test_default_constructor_is_none(self):
2870
config = DataverseConfig()
2971
self.assertIsNone(config.operation_context)
3072

3173

3274
class TestOperationContextClient(unittest.TestCase):
33-
"""Tests for operation_context kwarg on DataverseClient."""
75+
"""Tests for context kwarg on DataverseClient."""
3476

3577
def setUp(self):
3678
self.mock_credential = MagicMock(spec=TokenCredential)
3779
self.base_url = "https://example.crm.dynamics.com"
3880

3981
def test_kwarg_sets_config(self):
82+
ctx = OperationContext(operation_context="app=test/1.0;skill=dv-data;agent=claude-code")
4083
client = DataverseClient(
4184
self.base_url,
4285
self.mock_credential,
43-
operation_context="app=test/1.0;skill=dv-data;agent=claude-code",
86+
context=ctx,
87+
)
88+
self.assertEqual(
89+
client._config.operation_context.operation_context,
90+
"app=test/1.0;skill=dv-data;agent=claude-code",
4491
)
45-
self.assertEqual(client._config.operation_context, "app=test/1.0;skill=dv-data;agent=claude-code")
4692

4793
def test_no_kwarg_leaves_config_default(self):
4894
client = DataverseClient(self.base_url, self.mock_credential)
4995
self.assertIsNone(client._config.operation_context)
5096

51-
def test_config_and_kwarg_raises(self):
52-
config = DataverseConfig(operation_context="app=test/1.0")
97+
def test_config_and_context_raises(self):
98+
ctx = OperationContext(operation_context="app=test/1.0")
99+
config = DataverseConfig(operation_context=ctx)
53100
with self.assertRaises(ValueError):
54101
DataverseClient(
55102
self.base_url,
56103
self.mock_credential,
57104
config=config,
58-
operation_context="app=other/2.0",
105+
context=OperationContext(operation_context="app=other/2.0"),
59106
)
60107

61108
def test_config_alone_works(self):
62-
config = DataverseConfig(operation_context="app=test/1.0;agent=copilot")
109+
ctx = OperationContext(operation_context="app=test/1.0;agent=copilot")
110+
config = DataverseConfig(operation_context=ctx)
63111
client = DataverseClient(self.base_url, self.mock_credential, config=config)
64-
self.assertEqual(client._config.operation_context, "app=test/1.0;agent=copilot")
112+
self.assertEqual(
113+
client._config.operation_context.operation_context,
114+
"app=test/1.0;agent=copilot",
115+
)
65116

66117

67118
class TestOperationContextUserAgent(unittest.TestCase):
@@ -80,26 +131,19 @@ def test_default_user_agent_unchanged(self):
80131
self.assertEqual(headers["User-Agent"], _USER_AGENT)
81132

82133
def test_operation_context_appended(self):
83-
ctx = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"
134+
ctx_str = "app=dataverse-skills/1.2.1;skill=dv-data;agent=claude-code"
135+
ctx = OperationContext(operation_context=ctx_str)
84136
config = DataverseConfig(operation_context=ctx)
85137
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
86138
headers = odata._headers()
87-
self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx})")
139+
self.assertEqual(headers["User-Agent"], f"{_USER_AGENT} ({ctx_str})")
88140

89141
def test_none_context_no_parentheses(self):
90142
config = DataverseConfig(operation_context=None)
91143
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
92144
headers = odata._headers()
93145
self.assertNotIn("(", headers["User-Agent"])
94146

95-
def test_empty_string_context_no_parentheses(self):
96-
config = DataverseConfig(operation_context="")
97-
odata = _ODataClient(self.dummy_auth, self.base_url, config=config)
98-
headers = odata._headers()
99-
self.assertNotIn("(", headers["User-Agent"])
100-
101-
def test_control_chars_rejected(self):
102-
for bad in ["has\rnewline", "has\nnewline", "has\x00null"]:
103-
config = DataverseConfig(operation_context=bad)
104-
with self.assertRaises(ValueError):
105-
_ODataClient(self.dummy_auth, self.base_url, config=config)
147+
def test_empty_string_rejected_at_creation(self):
148+
with self.assertRaises(ValueError):
149+
OperationContext(operation_context="")

0 commit comments

Comments
 (0)