Skip to content

Commit 5ff5a77

Browse files
authored
Merge pull request #9 from coreweave/scim-shadow
feat: Add shadow map support to SCIM source and update documentation
2 parents e4693f3 + 8df3f96 commit 5ff5a77

File tree

4 files changed

+236
-2
lines changed

4 files changed

+236
-2
lines changed

examples/nsscache-scim.conf

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ source = scim
99
cache = files
1010

1111
# NSS maps to be cached
12-
maps = passwd, group, sshkey
12+
maps = passwd, group, shadow, sshkey
1313

1414
# Directory where cache files will be stored
1515
files_dir = /tmp/nsscache
@@ -75,6 +75,19 @@ scim_override_home_directory = /mnt/home/%%u
7575
scim_path_username = urn:example:params:scim:schemas:extension:User/userName
7676
scim_path_ssh_keys = urn:example:params:scim:schemas:extension:User/sshKeys
7777

78+
[shadow]
79+
80+
scim_path_username = urn:example:params:scim:schemas:extension:User/userName
81+
82+
# Optional shadow field defaults (all default to empty string if not specified)
83+
# scim_shadow_default_lstchg = # Last password change (days since Jan 1, 1970)
84+
# scim_shadow_default_min = # Minimum password age (days)
85+
# scim_shadow_default_max = 99999 # Maximum password age (days)
86+
# scim_shadow_default_warn = 7 # Password warning period (days)
87+
# scim_shadow_default_inact = # Password inactivity period (days)
88+
# scim_shadow_default_expire = # Account expiration date (days since Jan 1, 1970)
89+
# scim_shadow_default_flag = # Reserved field
90+
7891
[group]
7992

8093
scim_path_username = urn:example:params:scim:schemas:extension:User/userName

nss_cache/sources/scimsource.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from nss_cache import error
1111
from nss_cache.maps import group
1212
from nss_cache.maps import passwd
13+
from nss_cache.maps import shadow
1314
from nss_cache.maps import sshkey
1415
from nss_cache.sources import source
1516
from nss_cache.sources.httpsource import UpdateGetter as HttpUpdateGetter
@@ -161,6 +162,20 @@ def GetSshkeyMap(self, since=None):
161162
users_url = self._BuildUrlWithParameters(base_users_url, self.conf.get('users_parameters', ''))
162163
return SshkeyUpdateGetter(self.conf).GetUpdates(self, users_url, since)
163164

165+
def GetShadowMap(self, since=None):
166+
"""Return the shadow map from this source.
167+
168+
Args:
169+
since: Get data only changed since this timestamp (inclusive) or None
170+
for all data.
171+
172+
Returns:
173+
instance of shadow.ShadowMap
174+
"""
175+
base_users_url = f"{self.conf['base_url']}/{self.conf['users_endpoint']}"
176+
users_url = self._BuildUrlWithParameters(base_users_url, self.conf.get('users_parameters', ''))
177+
return ShadowUpdateGetter(self.conf).GetUpdates(self, users_url, since)
178+
164179
class UpdateGetter(HttpUpdateGetter):
165180
"""SCIM-specific update getter that extends HTTP functionality."""
166181

@@ -689,3 +704,56 @@ def _ExtractGroupMembers(self, group_data):
689704
members.append(member)
690705

691706
return members
707+
708+
709+
class ShadowUpdateGetter(UpdateGetter):
710+
"""Get Shadow updates from the SCIM Source."""
711+
712+
def __init__(self, conf):
713+
"""Initialize with configuration."""
714+
super().__init__()
715+
self.conf = conf
716+
717+
def GetParser(self):
718+
"""Returns a MapParser to parse SCIM shadow cache."""
719+
return ScimShadowMapParser(self.source)
720+
721+
def CreateMap(self):
722+
"""Returns a new ShadowMap instance to have ShadowMapEntries added to it."""
723+
return shadow.ShadowMap()
724+
725+
726+
class ScimShadowMapParser(ScimMapParser):
727+
"""A MapParser for SCIM shadow map data."""
728+
729+
def _ReadEntry(self, user_data):
730+
"""Return a ShadowMapEntry from a SCIM user resource."""
731+
username_path = self._GetMapConfig("scim_path_username", "userName")
732+
733+
# Extract username using the configured path
734+
username = self._ExtractFromPath(user_data, username_path)
735+
if not username:
736+
# Fallback to default userName field
737+
username = user_data.get("userName")
738+
739+
if not username:
740+
self.log.warning("Could not extract username from SCIM user object")
741+
return None
742+
743+
shadow_ent = shadow.ShadowMapEntry()
744+
shadow_ent.name = username
745+
746+
# Set shadow password to * since SCIM doesn't provide password data
747+
# This indicates authentication is handled elsewhere
748+
shadow_ent.passwd = "*"
749+
750+
# Set other shadow fields using configurable defaults
751+
shadow_ent.lstchg = self._GetMapConfig("scim_shadow_default_lstchg", "") # Last password change
752+
shadow_ent.min = self._GetMapConfig("scim_shadow_default_min", "") # Minimum password age
753+
shadow_ent.max = self._GetMapConfig("scim_shadow_default_max", "") # Maximum password age
754+
shadow_ent.warn = self._GetMapConfig("scim_shadow_default_warn", "") # Password warning period
755+
shadow_ent.inact = self._GetMapConfig("scim_shadow_default_inact", "") # Password inactivity period
756+
shadow_ent.expire = self._GetMapConfig("scim_shadow_default_expire", "") # Account expiration date
757+
shadow_ent.flag = self._GetMapConfig("scim_shadow_default_flag", "") # Reserved field
758+
759+
return shadow_ent

nss_cache/sources/scimsource_test.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from nss_cache import error
1010
from nss_cache.maps import group
1111
from nss_cache.maps import passwd
12+
from nss_cache.maps import shadow
1213
from nss_cache.maps import sshkey
1314

1415
from nss_cache.sources import scimsource
@@ -446,6 +447,24 @@ def testCreateMapMissingRequiredConfig(self):
446447
self.assertIn("scim_path_ssh_keys configuration is required", str(cm.exception))
447448

448449

450+
class TestScimShadowUpdateGetter(unittest.TestCase):
451+
def setUp(self):
452+
super(TestScimShadowUpdateGetter, self).setUp()
453+
self.config = {"path_username": "userName"}
454+
self.updater = scimsource.ShadowUpdateGetter(self.config)
455+
456+
def testGetParser(self):
457+
"""Test that GetParser returns correct parser type."""
458+
self.updater.source = mock.Mock()
459+
parser = self.updater.GetParser()
460+
self.assertTrue(isinstance(parser, scimsource.ScimShadowMapParser))
461+
462+
def testCreateMap(self):
463+
"""Test that CreateMap returns ShadowMap."""
464+
shadow_map = self.updater.CreateMap()
465+
self.assertTrue(isinstance(shadow_map, shadow.ShadowMap))
466+
467+
449468
class TestScimMapParser(unittest.TestCase):
450469
def setUp(self):
451470
super(TestScimMapParser, self).setUp()
@@ -779,5 +798,98 @@ def testReadEntryWithMissingMembers(self):
779798
self.assertEqual(entry.members, [])
780799

781800

801+
class TestScimShadowMapParser(unittest.TestCase):
802+
def setUp(self):
803+
super(TestScimShadowMapParser, self).setUp()
804+
self.config = {"path_username": "userName"}
805+
source = mock.Mock()
806+
source.conf = self.config
807+
self.parser = scimsource.ScimShadowMapParser(source)
808+
809+
def testReadEntryValidUser(self):
810+
"""Test _ReadEntry with valid user data."""
811+
user_data = {
812+
"userName": "testuser",
813+
"id": "1001"
814+
}
815+
816+
entry = self.parser._ReadEntry(user_data)
817+
818+
self.assertIsNotNone(entry)
819+
self.assertEqual(entry.name, "testuser")
820+
self.assertEqual(entry.passwd, "*")
821+
# All other shadow fields should be empty strings
822+
self.assertEqual(entry.lstchg, "")
823+
self.assertEqual(entry.min, "")
824+
self.assertEqual(entry.max, "")
825+
self.assertEqual(entry.warn, "")
826+
self.assertEqual(entry.inact, "")
827+
self.assertEqual(entry.expire, "")
828+
self.assertEqual(entry.flag, "")
829+
830+
def testReadEntryMissingUsername(self):
831+
"""Test _ReadEntry with missing username field."""
832+
user_data = {
833+
"id": "1002"
834+
}
835+
836+
entry = self.parser._ReadEntry(user_data)
837+
838+
self.assertIsNone(entry)
839+
840+
def testReadEntryWithCustomUsernamePath(self):
841+
"""Test _ReadEntry with custom username path."""
842+
custom_config = {"path_username": "urn:scim:schemas:extension:User/userName"}
843+
source = mock.Mock()
844+
source.conf = custom_config
845+
parser = scimsource.ScimShadowMapParser(source)
846+
847+
user_data = {
848+
"urn:scim:schemas:extension:User": {
849+
"userName": "customuser"
850+
},
851+
"id": "1003"
852+
}
853+
854+
entry = parser._ReadEntry(user_data)
855+
856+
self.assertIsNotNone(entry)
857+
self.assertEqual(entry.name, "customuser")
858+
self.assertEqual(entry.passwd, "*")
859+
860+
def testReadEntryWithCustomShadowDefaults(self):
861+
"""Test _ReadEntry with custom shadow field defaults."""
862+
custom_config = {
863+
"shadow_default_lstchg": "19000",
864+
"shadow_default_min": "0",
865+
"shadow_default_max": "99999",
866+
"shadow_default_warn": "7",
867+
"shadow_default_inact": "30",
868+
"shadow_default_expire": "20000",
869+
"shadow_default_flag": "0"
870+
}
871+
source = mock.Mock()
872+
source.conf = custom_config
873+
parser = scimsource.ScimShadowMapParser(source)
874+
875+
user_data = {
876+
"userName": "testuser",
877+
"id": "1001"
878+
}
879+
880+
entry = parser._ReadEntry(user_data)
881+
882+
self.assertIsNotNone(entry)
883+
self.assertEqual(entry.name, "testuser")
884+
self.assertEqual(entry.passwd, "*")
885+
# Verify custom shadow field defaults are used
886+
self.assertEqual(entry.lstchg, "19000")
887+
self.assertEqual(entry.min, "0")
888+
self.assertEqual(entry.max, "99999")
889+
self.assertEqual(entry.warn, "7")
890+
self.assertEqual(entry.inact, "30")
891+
self.assertEqual(entry.expire, "20000")
892+
self.assertEqual(entry.flag, "0")
893+
782894
if __name__ == "__main__":
783895
unittest.main()

nsscache.conf.5

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ scim_path_home_directory configuration and SCIM response data.
407407

408408
The following path configuration options allow customization of how data is
409409
extracted from SCIM responses. These can be set per-map in
410-
[passwd], [group], or [sshkey] sections:
410+
[passwd], [group], [shadow], or [sshkey] sections:
411411

412412
.TP
413413
.B scim_path_username
@@ -438,6 +438,47 @@ uses the scim_default_shell value.
438438
Path within SCIM user resources to extract SSH public keys. Should point to
439439
an array of SSH key strings or a single SSH key string.
440440

441+
.PP
442+
The shadow map uses user data from the SCIM users endpoint and creates
443+
shadow(5) format entries. It only requires scim_path_username configuration
444+
in the [shadow] section, as other shadow fields (password aging, etc.) are
445+
not typically available from SCIM sources. All shadow entries are created
446+
in the format: username:*::::::: where '*' indicates that authentication
447+
is handled elsewhere (not via local password files).
448+
449+
The following optional configuration parameters can be set in the [shadow]
450+
section to provide default values for shadow fields:
451+
452+
.TP
453+
.B scim_shadow_default_lstchg
454+
Default value for the last password change field (days since Jan 1, 1970).
455+
Defaults to empty string.
456+
457+
.TP
458+
.B scim_shadow_default_min
459+
Default value for the minimum password age field (days). Defaults to empty string.
460+
461+
.TP
462+
.B scim_shadow_default_max
463+
Default value for the maximum password age field (days). Defaults to empty string.
464+
465+
.TP
466+
.B scim_shadow_default_warn
467+
Default value for the password warning period field (days). Defaults to empty string.
468+
469+
.TP
470+
.B scim_shadow_default_inact
471+
Default value for the password inactivity period field (days). Defaults to empty string.
472+
473+
.TP
474+
.B scim_shadow_default_expire
475+
Default value for the account expiration date field (days since Jan 1, 1970).
476+
Defaults to empty string.
477+
478+
.TP
479+
.B scim_shadow_default_flag
480+
Default value for the reserved flag field. Defaults to empty string.
481+
441482
.SH files CACHE OPTIONS
442483
These options configure the behaviour of the
443484
.I files

0 commit comments

Comments
 (0)