Skip to content

Commit e72b31a

Browse files
samsternbergclaude
andcommitted
feat: add JSON object support for ctx field in bearer token and signed data token generation
Extend the Python SDK's bearer token and signed data token generation to accept a dict for the ctx field, in addition to the existing string type. This enables structured context for conditional data access policies where ctx object keys map to Skyflow CEL policy variables (e.g., request.context.role, request.context.department). Changes: - _utils.py: add _validate_and_resolve_ctx() function with key validation (^[a-zA-Z0-9_]+$), update get_signed_jwt() and get_signed_tokens() to validate and conditionally include ctx in JWT claims - _skyflow_messages.py: add INVALID_CTX_TYPE and INVALID_CTX_MAP_KEY errors - Tests: add 14+ validation test cases for dict ctx, invalid keys, invalid types, empty dict, nested objects, mixed value types - Samples: add JSON object context examples for both bearer and signed tokens - README: document both string and dict ctx patterns with CEL policy variable mapping Technical note: PyJWT's jwt.encode() already handles both types — a string serializes as a JSON string, a dict serializes as a JSON object in the JWT payload. The main addition is proper validation and error handling. Resolves: SK-2681, DOCU-1440 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad095e4 commit e72b31a

6 files changed

Lines changed: 233 additions & 46 deletions

File tree

README.md

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -703,18 +703,65 @@ options = {
703703

704704
Embed context values into a bearer token during generation so you can reference those values in your policies. This enables more flexible access controls, such as tracking end-user identity when making API calls using service accounts, and facilitates using signed data tokens during detokenization.
705705

706-
Generate bearer tokens containing context information using a service account with the context_id identifier. Context information is represented as a JWT claim in a Skyflow-generated bearer token. Tokens generated from such service accounts include a context_identifier claim, are valid for 60 minutes, and can be used to make API calls to the Data and Management APIs, depending on the service account's permissions.
706+
Generate bearer tokens containing context information using a service account with the `context_id` identifier. Context information is represented as a JWT claim in a Skyflow-generated bearer token. Tokens generated from such service accounts include a `context_identifier` claim, are valid for 60 minutes, and can be used to make API calls to the Data and Management APIs, depending on the service account's permissions.
707+
708+
The `ctx` parameter accepts either a **string** or a **dict**:
709+
710+
**String context** — use when your policy references a single context value:
711+
712+
```python
713+
options = {'ctx': 'user_12345'}
714+
token, _ = generate_bearer_token(filepath, options)
715+
```
716+
717+
**Dict context** — use when your policy needs multiple context values for conditional data access. Each key in the dict maps to a Skyflow CEL policy variable under `request.context.*`:
718+
719+
```python
720+
options = {
721+
'ctx': {
722+
'role': 'admin',
723+
'department': 'finance',
724+
'user_id': 'user_12345',
725+
}
726+
}
727+
token, _ = generate_bearer_token(filepath, options)
728+
```
729+
730+
With the dict above, your Skyflow policies can reference `request.context.role`, `request.context.department`, and `request.context.user_id` to make conditional access decisions.
731+
732+
Dict keys must contain only alphanumeric characters and underscores (`[a-zA-Z0-9_]`). Invalid keys will raise a `SkyflowError`.
707733

708734
> [!TIP]
709-
> See the full example in the samples directory: [token_generation_with_context_example.py](samples/service_account/token_generation_with_context_example.py)
710-
> See [docs.skyflow.com](https://docs.skyflow.com) for more details on authentication, access control, and governance for Skyflow.
735+
> See the full example in the samples directory: [token_generation_with_context_example.py](samples/service_account/token_generation_with_context_example.py)
736+
> See Skyflow's [context-aware authorization](https://docs.skyflow.com) and [conditional data access](https://docs.skyflow.com) docs for policy variable syntax like `request.context.*`.
711737
712738
#### Generate signed data tokens: `generate_signed_data_tokens(filepath, options)`
713739

714740
Digitally sign data tokens with a service account's private key to add an extra layer of protection. Skyflow generates data tokens when sensitive data is inserted into the vault. Detokenize signed tokens only by providing the signed data token along with a bearer token generated from the service account's credentials. The service account must have the necessary permissions and context to successfully detokenize the signed data tokens.
715741

742+
The `ctx` parameter on signed data tokens also accepts either a **string** or a **dict**, using the same format as bearer tokens:
743+
744+
```python
745+
# String context
746+
options = {
747+
'ctx': 'user_12345',
748+
'data_tokens': ['dataToken1', 'dataToken2'],
749+
'time_to_live': 90,
750+
}
751+
752+
# Dict context
753+
options = {
754+
'ctx': {
755+
'role': 'analyst',
756+
'department': 'research',
757+
},
758+
'data_tokens': ['dataToken1', 'dataToken2'],
759+
'time_to_live': 90,
760+
}
761+
```
762+
716763
> [!TIP]
717-
> See the full example in the samples directory: [signed_token_generation_example.py](samples/service_account/signed_token_generation_example.py)
764+
> See the full example in the samples directory: [signed_token_generation_example.py](samples/service_account/signed_token_generation_example.py)
718765
> See [docs.skyflow.com](https://docs.skyflow.com) for more details on authentication, access control, and governance for Skyflow.
719766
720767
## Logging

samples/service_account/signed_token_generation_example.py

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,54 @@
1818
credentials_string = json.dumps(skyflow_credentials)
1919

2020

21-
options = {
22-
'ctx': 'CONTEXT_ID',
23-
'data_tokens': ['DATA_TOKEN1', 'DATA_TOKEN2'],
24-
'time_to_live': 90, # in seconds
25-
}
21+
# Approach 1: Signed data tokens with string context
22+
def get_signed_tokens_with_string_context():
23+
options = {
24+
'ctx': 'user_12345',
25+
'data_tokens': ['DATA_TOKEN1', 'DATA_TOKEN2'],
26+
'time_to_live': 90, # in seconds
27+
}
28+
try:
29+
data_token, signed_data_token = generate_signed_data_tokens(file_path, options)
30+
return data_token, signed_data_token
31+
except Exception as e:
32+
print(f'Error: {str(e)}')
2633

27-
def get_signed_bearer_token_from_file_path():
28-
# Generate signed bearer token from credentials file path.
29-
global bearer_token
3034

35+
# Approach 2: Signed data tokens with JSON object context (dict)
36+
# Each key maps to a Skyflow CEL policy variable under request.context.*
37+
# For example: request.context.role == "analyst" and request.context.department == "research"
38+
def get_signed_tokens_with_object_context():
39+
options = {
40+
'ctx': {
41+
'role': 'analyst',
42+
'department': 'research',
43+
'user_id': 'user_67890',
44+
},
45+
'data_tokens': ['DATA_TOKEN1', 'DATA_TOKEN2'],
46+
'time_to_live': 90,
47+
}
3148
try:
32-
if not is_expired(bearer_token):
33-
return bearer_token
34-
else:
35-
data_token, signed_data_token = generate_signed_data_tokens(file_path, options)
36-
return data_token, signed_data_token
37-
49+
data_token, signed_data_token = generate_signed_data_tokens(file_path, options)
50+
return data_token, signed_data_token
3851
except Exception as e:
39-
print(f'Error generating token from file path: {str(e)}')
52+
print(f'Error: {str(e)}')
4053

4154

42-
def get_signed_bearer_token_from_credentials_string():
43-
# Generate signed bearer token from credentials string.
44-
global bearer_token
45-
55+
# Approach 3: Signed data tokens from credentials string
56+
def get_signed_tokens_from_credentials_string():
57+
options = {
58+
'ctx': 'user_12345',
59+
'data_tokens': ['DATA_TOKEN1', 'DATA_TOKEN2'],
60+
'time_to_live': 90,
61+
}
4662
try:
47-
if not is_expired(bearer_token):
48-
return bearer_token
49-
else:
50-
data_token, signed_data_token = generate_signed_data_tokens_from_creds(credentials_string, options)
51-
return data_token, signed_data_token
52-
63+
data_token, signed_data_token = generate_signed_data_tokens_from_creds(credentials_string, options)
64+
return data_token, signed_data_token
5365
except Exception as e:
54-
print(f'Error generating token from credentials string: {str(e)}')
55-
66+
print(f'Error: {str(e)}')
5667

57-
print(get_signed_bearer_token_from_file_path())
5868

59-
print(get_signed_bearer_token_from_credentials_string())
69+
print("String context:", get_signed_tokens_with_string_context())
70+
print("Object context:", get_signed_tokens_with_object_context())
71+
print("Creds string:", get_signed_tokens_from_credentials_string())

samples/service_account/token_generation_with_context_example.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
}
1919
credentials_string = json.dumps(skyflow_credentials)
2020

21-
options = {'ctx': '<CONTEXT_ID>'}
2221

23-
def get_bearer_token_with_context_from_file_path():
24-
# Generate bearer token with context from credentials file path.
22+
# Approach 1: Bearer token with string context
23+
# Use a simple string identifier when your policy references a single context value.
24+
# In your Skyflow policy, reference this as: request.context
25+
def get_bearer_token_with_string_context():
2526
global bearer_token
27+
options = {'ctx': 'user_12345'}
2628

2729
try:
2830
if not is_expired(bearer_token):
@@ -31,14 +33,40 @@ def get_bearer_token_with_context_from_file_path():
3133
token, _ = generate_bearer_token(file_path, options)
3234
bearer_token = token
3335
return bearer_token
36+
except Exception as e:
37+
print(f'Error generating token: {str(e)}')
38+
39+
40+
# Approach 2: Bearer token with JSON object context (dict)
41+
# Use a dict when your policy needs multiple context values for conditional data access.
42+
# Each key maps to a Skyflow CEL policy variable under request.context.*
43+
# For example: request.context.role == "admin" and request.context.department == "finance"
44+
def get_bearer_token_with_object_context():
45+
global bearer_token
46+
options = {
47+
'ctx': {
48+
'role': 'admin',
49+
'department': 'finance',
50+
'user_id': 'user_12345',
51+
}
52+
}
3453

54+
try:
55+
if not is_expired(bearer_token):
56+
return bearer_token
57+
else:
58+
token, _ = generate_bearer_token(file_path, options)
59+
bearer_token = token
60+
return bearer_token
3561
except Exception as e:
36-
print(f'Error generating token from file path: {str(e)}')
62+
print(f'Error generating token: {str(e)}')
3763

3864

65+
# Approach 3: Bearer token with string context from credentials string
3966
def get_bearer_token_with_context_from_credentials_string():
40-
# Generate bearer token with context from credentials string.
4167
global bearer_token
68+
options = {'ctx': 'user_12345'}
69+
4270
try:
4371
if not is_expired(bearer_token):
4472
return bearer_token
@@ -47,9 +75,9 @@ def get_bearer_token_with_context_from_credentials_string():
4775
bearer_token = token
4876
return bearer_token
4977
except Exception as e:
50-
print(f"Error generating token from credentials string: {str(e)}")
51-
78+
print(f"Error generating token: {str(e)}")
5279

53-
print(get_bearer_token_with_context_from_file_path())
5480

55-
print(get_bearer_token_with_context_from_credentials_string())
81+
print("String context:", get_bearer_token_with_string_context())
82+
print("Object context:", get_bearer_token_with_object_context())
83+
print("Creds string:", get_bearer_token_with_context_from_credentials_string())

skyflow/service_account/_utils.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import datetime
3+
import re
34
import time
45
import jwt
56
from skyflow.error import SkyflowError
@@ -10,6 +11,34 @@
1011

1112
invalid_input_error_code = SkyflowMessages.ErrorCodes.INVALID_INPUT.value
1213

14+
_CTX_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9_]+$')
15+
16+
17+
def _validate_and_resolve_ctx(ctx):
18+
"""Validate ctx value and return resolved value for JWT claims.
19+
Returns None if ctx should be omitted, the value if valid, or raises SkyflowError if invalid.
20+
"""
21+
if ctx is None:
22+
return None
23+
if isinstance(ctx, str):
24+
if ctx.strip() == '':
25+
return None
26+
return ctx
27+
if isinstance(ctx, dict):
28+
if len(ctx) == 0:
29+
return None
30+
for key in ctx:
31+
if not isinstance(key, str) or not _CTX_KEY_PATTERN.match(key):
32+
raise SkyflowError(
33+
SkyflowMessages.Error.INVALID_CTX_MAP_KEY.value.format(key),
34+
invalid_input_error_code
35+
)
36+
return ctx
37+
raise SkyflowError(
38+
SkyflowMessages.Error.INVALID_CTX_TYPE.value,
39+
invalid_input_error_code
40+
)
41+
1342
def is_expired(token, logger = None):
1443
if len(token) == 0:
1544
log_error_log(SkyflowMessages.ErrorLogs.INVALID_BEARER_TOKEN.value)
@@ -103,7 +132,9 @@ def get_signed_jwt(options, client_id, key_id, token_uri, private_key, logger):
103132
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=60)
104133
}
105134
if options and "ctx" in options:
106-
payload["ctx"] = options.get("ctx")
135+
resolved_ctx = _validate_and_resolve_ctx(options.get("ctx"))
136+
if resolved_ctx is not None:
137+
payload["ctx"] = resolved_ctx
107138
try:
108139
return jwt.encode(payload=payload, key=private_key, algorithm="RS256")
109140
except Exception:
@@ -128,7 +159,9 @@ def get_signed_tokens(credentials_obj, options):
128159
}
129160

130161
if "ctx" in options:
131-
claims["ctx"] = options["ctx"]
162+
resolved_ctx = _validate_and_resolve_ctx(options["ctx"])
163+
if resolved_ctx is not None:
164+
claims["ctx"] = resolved_ctx
132165

133166
private_key = credentials_obj.get("privateKey")
134167
signed_jwt = jwt.encode(claims, private_key, algorithm="RS256")

skyflow/utils/_skyflow_messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ class Error(Enum):
6060
EMPTY_CONTEXT = f"{error_prefix} Initialization failed. Invalid context provided. Specify context as type Context."
6161
INVALID_CONTEXT_IN_CONFIG = f"{error_prefix} Initialization failed. Invalid context for {{}} with id {{}}. Specify a valid context."
6262
INVALID_CONTEXT = f"{error_prefix} Initialization failed. Invalid context. Specify a valid context."
63+
INVALID_CTX_TYPE = f"{error_prefix} Initialization failed. Invalid ctx type. Specify ctx as a string or a dict."
64+
INVALID_CTX_MAP_KEY = f"{error_prefix} Initialization failed. Invalid key '{{}}' in ctx dict. Keys must contain only alphanumeric characters and underscores."
6365
INVALID_LOG_LEVEL = f"{error_prefix} Initialization failed. Invalid log level. Specify a valid log level."
6466
EMPTY_LOG_LEVEL = f"{error_prefix} Initialization failed. Specify a valid log level."
6567

tests/service_account/test__utils.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from skyflow.service_account import is_expired, generate_bearer_token, \
99
generate_bearer_token_from_creds
1010
from skyflow.utils import SkyflowMessages
11-
from skyflow.service_account._utils import get_service_account_token, get_signed_jwt, generate_signed_data_tokens, get_signed_data_token_response_object, generate_signed_data_tokens_from_creds
11+
from skyflow.service_account._utils import get_service_account_token, get_signed_jwt, generate_signed_data_tokens, get_signed_data_token_response_object, generate_signed_data_tokens_from_creds, _validate_and_resolve_ctx
1212

1313
creds_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "credentials.json")
1414
with open(creds_path, 'r') as file:
@@ -143,4 +143,69 @@ def test_generate_signed_data_tokens_from_creds_with_invalid_string(self):
143143
credentials_string = '{'
144144
with self.assertRaises(SkyflowError) as context:
145145
result = generate_signed_data_tokens_from_creds(credentials_string, options)
146-
self.assertEqual(context.exception.message, SkyflowMessages.Error.INVALID_CREDENTIALS_STRING.value)
146+
self.assertEqual(context.exception.message, SkyflowMessages.Error.INVALID_CREDENTIALS_STRING.value)
147+
148+
# ctx JSON object support tests
149+
150+
def test_validate_and_resolve_ctx_none(self):
151+
self.assertIsNone(_validate_and_resolve_ctx(None))
152+
153+
def test_validate_and_resolve_ctx_empty_string(self):
154+
self.assertIsNone(_validate_and_resolve_ctx(''))
155+
self.assertIsNone(_validate_and_resolve_ctx(' '))
156+
157+
def test_validate_and_resolve_ctx_valid_string(self):
158+
self.assertEqual(_validate_and_resolve_ctx('user_12345'), 'user_12345')
159+
160+
def test_validate_and_resolve_ctx_empty_dict(self):
161+
self.assertIsNone(_validate_and_resolve_ctx({}))
162+
163+
def test_validate_and_resolve_ctx_valid_dict(self):
164+
ctx = {"role": "admin", "department": "finance"}
165+
self.assertEqual(_validate_and_resolve_ctx(ctx), ctx)
166+
167+
def test_validate_and_resolve_ctx_dict_with_alphanumeric_keys(self):
168+
ctx = {"role_1": "admin", "dept2": "finance", "ABC_123": "value"}
169+
self.assertEqual(_validate_and_resolve_ctx(ctx), ctx)
170+
171+
def test_validate_and_resolve_ctx_dict_with_invalid_key_hyphen(self):
172+
ctx = {"valid_key": "value", "invalid-key": "value"}
173+
with self.assertRaises(SkyflowError):
174+
_validate_and_resolve_ctx(ctx)
175+
176+
def test_validate_and_resolve_ctx_dict_with_invalid_key_space(self):
177+
ctx = {"invalid key": "value"}
178+
with self.assertRaises(SkyflowError):
179+
_validate_and_resolve_ctx(ctx)
180+
181+
def test_validate_and_resolve_ctx_dict_with_invalid_key_dot(self):
182+
ctx = {"invalid.key": "value"}
183+
with self.assertRaises(SkyflowError):
184+
_validate_and_resolve_ctx(ctx)
185+
186+
def test_validate_and_resolve_ctx_invalid_type_int(self):
187+
with self.assertRaises(SkyflowError):
188+
_validate_and_resolve_ctx(42)
189+
190+
def test_validate_and_resolve_ctx_invalid_type_list(self):
191+
with self.assertRaises(SkyflowError):
192+
_validate_and_resolve_ctx(["a", "b"])
193+
194+
def test_validate_and_resolve_ctx_dict_with_mixed_value_types(self):
195+
ctx = {"role": "admin", "level": 3, "active": True, "timestamp": "2025-12-25T10:30:00Z"}
196+
self.assertEqual(_validate_and_resolve_ctx(ctx), ctx)
197+
198+
def test_validate_and_resolve_ctx_dict_with_nested_objects(self):
199+
ctx = {"role": "admin", "metadata": {"level": 2, "tags": ["a", "b"]}}
200+
self.assertEqual(_validate_and_resolve_ctx(ctx), ctx)
201+
202+
def test_generate_signed_data_tokens_with_dict_ctx(self):
203+
creds_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "credentials.json")
204+
options = {"data_tokens": ["token1"], "ctx": {"role": "admin", "department": "finance"}}
205+
result = generate_signed_data_tokens(creds_path, options)
206+
self.assertEqual(len(result), 2)
207+
208+
def test_generate_signed_data_tokens_from_creds_with_dict_ctx(self):
209+
options = {"data_tokens": ["token1"], "ctx": {"role": "admin", "level": 3}}
210+
result = generate_signed_data_tokens_from_creds(VALID_CREDENTIALS_STRING, options)
211+
self.assertEqual(len(result), 2)

0 commit comments

Comments
 (0)