Skip to content

Commit 5b1b4b8

Browse files
authored
Merge pull request #5 from coreweave/scim-filtering
feat: Add support for SCIM resource IDs and update documentation and tests
2 parents 2cfc738 + fcde024 commit 5b1b4b8

File tree

4 files changed

+216
-3
lines changed

4 files changed

+216
-3
lines changed

examples/nsscache-scim.conf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ scim_auth_token = <token>
3434
scim_users_endpoint = Users
3535
scim_groups_endpoint = Groups
3636

37+
# (Optional) SCIM url query parameters
38+
# Special characters (spaces, quotes, etc.) will be automatically URL encoded
39+
scim_users_parameters = groups=admin&filter=active eq "true"
40+
scim_groups_parameters = filter=displayName eq "users" or displayName eq "admin"
41+
3742
# (Optional) SCIM options
3843
scim_timeout = 60
3944
scim_verify_ssl = True

nss_cache/sources/scimsource.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pycurl
66
import os
77
from io import StringIO
8+
from urllib.parse import urlencode
89

910
from nss_cache import error
1011
from nss_cache.maps import group
@@ -66,6 +67,10 @@ def _SetDefaults(self, configuration):
6667
configuration["users_endpoint"] = self.USERS_ENDPOINT
6768
if "groups_endpoint" not in configuration:
6869
configuration["groups_endpoint"] = self.GROUPS_ENDPOINT
70+
if "users_parameters" not in configuration:
71+
configuration["users_parameters"] = ""
72+
if "groups_parameters" not in configuration:
73+
configuration["groups_parameters"] = ""
6974
if "timeout" not in configuration:
7075
configuration["timeout"] = self.TIMEOUT
7176
if "verify_ssl" not in configuration:
@@ -78,6 +83,42 @@ def _SetDefaults(self, configuration):
7883
configuration["default_shell"] = self.DEFAULT_SHELL
7984
if "auth_token" not in configuration:
8085
configuration["auth_token"] = os.environ.get('NSSCACHE_SCIM_AUTH_TOKEN', self.AUTH_TOKEN)
86+
87+
def _BuildUrlWithParameters(self, base_url, parameters):
88+
"""Build URL with custom parameters, handling proper encoding.
89+
90+
Args:
91+
base_url: The base URL (e.g., "https://api.example.com/scim/Users")
92+
parameters: Parameter string (e.g., "groups=admin&filter=active eq \"true\"")
93+
94+
Returns:
95+
URL with parameters properly encoded and appended
96+
"""
97+
if not parameters or not parameters.strip():
98+
return base_url
99+
100+
# Clean up parameters - remove leading ? or & if present
101+
clean_params = parameters.strip().lstrip('?&')
102+
103+
if not clean_params:
104+
return base_url
105+
106+
# Parse the parameters to properly encode them
107+
# This preserves the original parameter structure while encoding special characters
108+
params_dict = {}
109+
for param in clean_params.split('&'):
110+
if '=' in param:
111+
key, value = param.split('=', 1)
112+
params_dict[key] = value
113+
else:
114+
# Handle case where there's no = sign (shouldn't happen, but just in case)
115+
params_dict[param] = ''
116+
117+
encoded_query = urlencode(params_dict)
118+
119+
# Add parameters to base URL
120+
return f"{base_url}?{encoded_query}"
121+
81122
def GetPasswdMap(self, since=None):
82123
"""Return the passwd map from this source.
83124
@@ -88,7 +129,8 @@ def GetPasswdMap(self, since=None):
88129
Returns:
89130
instance of passwd.PasswdMap
90131
"""
91-
users_url = f"{self.conf['base_url']}/{self.conf['users_endpoint']}"
132+
base_users_url = f"{self.conf['base_url']}/{self.conf['users_endpoint']}"
133+
users_url = self._BuildUrlWithParameters(base_users_url, self.conf.get('users_parameters', ''))
92134
return PasswdUpdateGetter(self.conf).GetUpdates(self, users_url, since)
93135

94136
def GetGroupMap(self, since=None):
@@ -101,7 +143,8 @@ def GetGroupMap(self, since=None):
101143
Returns:
102144
instance of group.GroupMap
103145
"""
104-
groups_url = f"{self.conf['base_url']}/{self.conf['groups_endpoint']}"
146+
base_groups_url = f"{self.conf['base_url']}/{self.conf['groups_endpoint']}"
147+
groups_url = self._BuildUrlWithParameters(base_groups_url, self.conf.get('groups_parameters', ''))
105148
return GroupUpdateGetter(self.conf).GetUpdates(self, groups_url, since)
106149

107150
def GetSshkeyMap(self, since=None):
@@ -114,7 +157,8 @@ def GetSshkeyMap(self, since=None):
114157
Returns:
115158
instance of sshkey.SshkeyMap
116159
"""
117-
users_url = f"{self.conf['base_url']}/{self.conf['users_endpoint']}"
160+
base_users_url = f"{self.conf['base_url']}/{self.conf['users_endpoint']}"
161+
users_url = self._BuildUrlWithParameters(base_users_url, self.conf.get('users_parameters', ''))
118162
return SshkeyUpdateGetter(self.conf).GetUpdates(self, users_url, since)
119163

120164
class UpdateGetter(HttpUpdateGetter):

nss_cache/sources/scimsource_test.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,61 @@ def testVerifySslDisabled(self):
104104
mock_conn.setopt.assert_any_call(pycurl.SSL_VERIFYPEER, 0)
105105
mock_conn.setopt.assert_any_call(pycurl.SSL_VERIFYHOST, 0)
106106

107+
def testBuildUrlWithParameters(self):
108+
"""Test that URL parameters are properly handled."""
109+
config = {
110+
"base_url": "https://api.example.com/scim",
111+
"auth_token": "test_token"
112+
}
113+
source = scimsource.ScimSource(config)
114+
115+
# Test with no parameters
116+
url = source._BuildUrlWithParameters("https://api.example.com/scim/Users", "")
117+
self.assertEqual(url, "https://api.example.com/scim/Users")
118+
119+
# Test with simple parameters (comma gets URL encoded)
120+
url = source._BuildUrlWithParameters("https://api.example.com/scim/Users", "groups=users,admin")
121+
self.assertEqual(url, "https://api.example.com/scim/Users?groups=users%2Cadmin")
122+
123+
# Test with parameters that have leading ? or &
124+
url = source._BuildUrlWithParameters("https://api.example.com/scim/Users", "?groups=users,admin")
125+
self.assertEqual(url, "https://api.example.com/scim/Users?groups=users%2Cadmin")
126+
127+
url = source._BuildUrlWithParameters("https://api.example.com/scim/Users", "&groups=users,admin")
128+
self.assertEqual(url, "https://api.example.com/scim/Users?groups=users%2Cadmin")
129+
130+
# Test with complex SCIM filter that needs encoding
131+
url = source._BuildUrlWithParameters("https://api.example.com/scim/Groups", 'filter=displayName eq "users"')
132+
self.assertEqual(url, "https://api.example.com/scim/Groups?filter=displayName+eq+%22users%22")
133+
134+
# Test with multiple parameters
135+
url = source._BuildUrlWithParameters("https://api.example.com/scim/Users", "groups=admin,metrics&filter=active eq \"true\"")
136+
self.assertEqual(url, "https://api.example.com/scim/Users?groups=admin%2Cmetrics&filter=active+eq+%22true%22")
137+
138+
def testParametersConfiguration(self):
139+
"""Test that users_parameters and groups_parameters are configured properly."""
140+
config = {
141+
"base_url": "https://api.example.com/scim",
142+
"auth_token": "test_token",
143+
"users_parameters": "groups=users,admin&filter=active",
144+
"groups_parameters": "type=security"
145+
}
146+
source = scimsource.ScimSource(config)
147+
148+
self.assertEqual(source.conf["users_parameters"], "groups=users,admin&filter=active")
149+
self.assertEqual(source.conf["groups_parameters"], "type=security")
150+
151+
def testParametersDefaultsToEmpty(self):
152+
"""Test that parameters default to empty strings."""
153+
config = {
154+
"base_url": "https://api.example.com/scim",
155+
"auth_token": "test_token"
156+
}
157+
source = scimsource.ScimSource(config)
158+
159+
self.assertEqual(source.conf["users_parameters"], "")
160+
self.assertEqual(source.conf["groups_parameters"], "")
161+
107162

108163
class TestScimUpdateGetter(unittest.TestCase):
109164
def setUp(self):
@@ -203,6 +258,103 @@ def mock_get_map(cache_info, data):
203258
self.assertIn("Users", call_args[0][0][0]) # First call should be to base URL
204259
self.assertIn("startIndex=51", call_args[1][0][0]) # Second call should have pagination
205260

261+
def testGetUpdatesWithPaginationAndCustomParameters(self):
262+
"""Test that pagination works correctly with custom URL parameters."""
263+
mock_conn = mock.Mock()
264+
mock_conn.getinfo.return_value = 200
265+
self.curl_mock.return_value = mock_conn
266+
267+
config = {
268+
"base_url": "https://api.example.com/scim",
269+
"auth_token": "test_token"
270+
}
271+
source = scimsource.ScimSource(config)
272+
273+
# Mock the first page response with pagination info
274+
first_page_response = {
275+
"totalResults": 75,
276+
"itemsPerPage": 50,
277+
"startIndex": 1,
278+
"Resources": [{"id": str(i), "userName": f"user{i}"} for i in range(1, 51)]
279+
}
280+
281+
# Mock the second page response
282+
second_page_response = {
283+
"totalResults": 75,
284+
"itemsPerPage": 25,
285+
"startIndex": 51,
286+
"Resources": [{"id": str(i), "userName": f"user{i}"} for i in range(51, 76)]
287+
}
288+
289+
with mock.patch.object(curl, 'CurlFetch') as mock_curl_fetch:
290+
mock_curl_fetch.side_effect = [
291+
(200, "", json.dumps(first_page_response).encode('utf-8')),
292+
(200, "", json.dumps(second_page_response).encode('utf-8'))
293+
]
294+
295+
getter = scimsource.UpdateGetter()
296+
getter.source = source
297+
298+
# Mock the parser and its pagination metadata
299+
mock_parser = mock.Mock()
300+
301+
# Mock the first map returned by GetMap
302+
mock_first_map = mock.Mock()
303+
mock_first_map.__len__ = mock.Mock(return_value=50)
304+
305+
# Mock the second map returned by GetMap
306+
mock_second_map = mock.Mock()
307+
mock_second_map.__len__ = mock.Mock(return_value=75) # Total items after both pages
308+
309+
# Track which call we're on
310+
call_count = 0
311+
312+
# Configure GetMap to return the mocked maps and update pagination metadata
313+
def mock_get_map(cache_info, data):
314+
nonlocal call_count
315+
call_count += 1
316+
317+
if call_count == 1:
318+
# First page
319+
mock_parser._pagination_metadata = {
320+
'totalResults': 75,
321+
'itemsPerPage': 50,
322+
'startIndex': 1
323+
}
324+
return mock_first_map
325+
else:
326+
# Second page
327+
mock_parser._pagination_metadata = {
328+
'totalResults': 75,
329+
'itemsPerPage': 25,
330+
'startIndex': 51
331+
}
332+
return mock_second_map
333+
334+
mock_parser.GetMap = mock.Mock(side_effect=mock_get_map)
335+
336+
getter.GetParser = mock.Mock(return_value=mock_parser)
337+
getter.CreateMap = mock.Mock(return_value=mock.Mock())
338+
339+
# Test with URL that has custom parameters
340+
result = getter.GetUpdates(source, "https://api.example.com/scim/Users?groups=users,admin", None)
341+
342+
# Should call CurlFetch twice (first page + second page)
343+
self.assertEqual(mock_curl_fetch.call_count, 2)
344+
345+
# Should call GetMap twice (first page + second page)
346+
self.assertEqual(mock_parser.GetMap.call_count, 2)
347+
348+
# Verify the URLs include both custom parameters and pagination parameters
349+
call_args = mock_curl_fetch.call_args_list
350+
# First call should include custom parameters and startIndex=1
351+
self.assertIn("groups=users,admin", call_args[0][0][0])
352+
self.assertIn("startIndex=1", call_args[0][0][0])
353+
354+
# Second call should include custom parameters and startIndex=51
355+
self.assertIn("groups=users,admin", call_args[1][0][0])
356+
self.assertIn("startIndex=51", call_args[1][0][0])
357+
206358

207359
class TestScimPasswdUpdateGetter(unittest.TestCase):
208360
def setUp(self):

nsscache.conf.5

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,18 @@ The SCIM endpoint path for retrieving user data. Defaults to
355355
The SCIM endpoint path for retrieving group data. Defaults to
356356
.I Groups
357357

358+
.TP
359+
.B scim_users_parameters
360+
Optional url parameters for the users endpoint to be added.
361+
Special characters (spaces, quotes, etc.) will be automatically URL encoded.
362+
Example: 'groups=admin&filter=active eq true'
363+
364+
.TP
365+
.B scim_groups_parameters
366+
Optional url parameters for the groups endpoint to be added.
367+
Special characters (spaces, quotes, etc.) will be automatically URL encoded.
368+
Example: 'filter=displayName eq users or displayName eq admin'
369+
358370
.TP
359371
.B scim_timeout
360372
Timeout in seconds for SCIM requests. Defaults to 60.

0 commit comments

Comments
 (0)