├── .editorconfig ├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── setup.cfg ├── setup.py ├── src └── scim │ ├── __init__.py │ └── schema │ ├── __init__.py │ ├── attributes.py │ ├── core.py │ ├── tenant.py │ ├── types.py │ └── user.py └── tests ├── conftest.py └── scim └── test_schema.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dot files 2 | .* 3 | !.editorconfig 4 | !.gitattributes 5 | !.gitignore 6 | !.travis* 7 | 8 | # Python 9 | /dist 10 | /build 11 | *.pyc 12 | *.pyo 13 | *.egg-info 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.3' 4 | 5 | install: 6 | - 'travis_retry pip install -q -e ".[test]" --use-mirrors' 7 | - 'travis_retry pip install coveralls --use-mirrors' 8 | 9 | script: 'py.test --pep8 --cov scim' 10 | 11 | after_success: 'coveralls' 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2012-2013 by Concordus Applications, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-scim 2 | [![Build Status](https://travis-ci.org/concordusapps/python-scim.png?branch=master)](https://travis-ci.org/concordusapps/python-scim) 3 | [![Coverage Status](https://coveralls.io/repos/concordusapps/python-scim/badge.png?branch=master)](https://coveralls.io/r/concordusapps/python-scim?branch=master) 4 | [![PyPi Version](https://pypip.in/v/scim/badge.png)](https://pypi.python.org/pypi/scim) 5 | ![PyPi Downloads](https://pypip.in/d/scim/badge.png) 6 | > A python interface to produce and consume System for Cross-domain Identity Management (SCIM) messages. 7 | 8 | ## Features 9 | 10 | ##### SCIM conformance 11 | 12 | python-scim conforms to the latest [SCIM][] (v1.1) standards. 13 | 14 | [SCIM]: http://www.simplecloud.info/ 15 | 16 | ##### Environment agnostic 17 | 18 | python-scim may be used to produce and consume SCIM messages regardless of the environment (terminal, WSGI, django) used to call it. 19 | 20 | 21 | ## Installation 22 | 23 | ### Automated 24 | 25 | 1. **python-scim** is listed on [PyPI](https://pypi.python.org/pypi/) 26 | and can be installed by running the following command: 27 | 28 | ```sh 29 | pip install scim 30 | ``` 31 | 32 | ## License 33 | Unless otherwise noted, all files contained within this project are liensed under the MIT opensource license. See the included file LICENSE or visit [opensource.org][] for more information. 34 | 35 | [opensource.org]: http://opensource.org/licenses/MIT 36 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -rx -s 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup, find_packages 4 | from pkgutil import get_importer 5 | from os import path 6 | 7 | # Calculate the base directory of the project. 8 | BASE_DIR = path.abspath(path.dirname(__file__)) 9 | 10 | # Navigate, import, and retrieve the version of the project. 11 | VERSION = get_importer(path.join(BASE_DIR, 'src')).find_module( 12 | 'scim').load_module().__version__ 13 | 14 | setup( 15 | name='scim', 16 | version=VERSION, 17 | description='A python interface to produce and consume System for' 18 | ' Cross-domain Identity Management (SCIM) messages.', 19 | classifiers=[ 20 | 'Development Status :: 3 - Alpha', 21 | 'Intended Audience :: Developers', 22 | 'Intended Audience :: System Administrators', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python :: 3.3' 26 | ], 27 | author='Concordus Applications', 28 | author_email='support@concordusapps.com', 29 | url='http://github.com/concordusapps/python-scim', 30 | package_dir={'scim': 'src/scim'}, 31 | packages=find_packages(path.join(BASE_DIR, 'src')), 32 | install_requires=( 33 | # Extensions to the standard Python datetime module. 34 | # Provides ability to easily parse ISO 8601 formatted dates. 35 | 'python-dateutil' 36 | ), 37 | extras_require={ 38 | 'test': ( 39 | # Test runner. 40 | 'pytest', 41 | 42 | # Ensure PEP8 conformance. 43 | 'pytest-pep8', 44 | 45 | # Ensure test coverage. 46 | 'pytest-cov', 47 | ) 48 | } 49 | ) 50 | -------------------------------------------------------------------------------- /src/scim/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """System for Cross-Domain Identity Management (SCIM) v1.1 3 | 4 | @par References 5 | - http://www.simplecloud.info/specs/draft-scim-core-schema-01.html 6 | - http://www.simplecloud.info/specs/draft-scim-api-01.html 7 | """ 8 | 9 | #! Version of the package. 10 | __version__ = VERSION = "0.2.1" 11 | 12 | #! Version of the SCIM standard supported. 13 | SCIM_VERSION = "1.1" 14 | -------------------------------------------------------------------------------- /src/scim/schema/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .core import Base 3 | from .user import User, EnterpriseUser 4 | from .tenant import Tenant 5 | 6 | __all__ = [ 7 | 'Base', 8 | 'User', 'EnterpriseUser', 9 | 'Tenant' 10 | ] 11 | -------------------------------------------------------------------------------- /src/scim/schema/attributes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import collections 3 | import json 4 | import re 5 | from .types import Boolean, String 6 | 7 | 8 | def camelize(name): 9 | name = name.strip() 10 | pattern = r'[-_\s]+(.)?' 11 | name = re.sub(pattern, lambda m: m.groups()[0].upper() if m else '', name) 12 | return name 13 | 14 | 15 | class Declarative(type): 16 | 17 | @classmethod 18 | def __prepare__(cls, name, bases): 19 | return collections.OrderedDict() 20 | 21 | def __new__(cls, name, bases, attrs): 22 | # Collect declared attributes. 23 | attrs['_attributes'] = collections.OrderedDict() 24 | 25 | # Collect attributes from base classes. 26 | for base in bases: 27 | values = getattr(base, '_attributes', None) 28 | if values: 29 | attrs['_attributes'].update(values) 30 | 31 | # Collect attributes from current class. 32 | for key, attr in attrs.items(): 33 | if isinstance(type(attr), cls): 34 | # If name reference is null; default to camel-cased name. 35 | if attr.name is None: 36 | attr.name = camelize(key) 37 | 38 | # Store attribute in dictionary. 39 | attrs['_attributes'][attr.name] = attr 40 | 41 | # Collect schemas names from base classes. 42 | attrs['schemas'] = schemas = [] 43 | for base in bases: 44 | values = getattr(base, 'schemas', None) 45 | if values: 46 | schemas.extend(values) 47 | 48 | # Append the current schema. 49 | options = attrs.get('Meta') 50 | schema = getattr(options, 'schema', None) 51 | if schema: 52 | schemas.append(schema) 53 | 54 | # Continue initialization. 55 | return super().__new__(cls, name, bases, attrs) 56 | 57 | 58 | class Base(metaclass=Declarative): 59 | """ 60 | Represents an attribute wrapper; intended to tie a name, constraints, 61 | and behavior to a type. 62 | """ 63 | 64 | def __init__(self, last=False): 65 | #! Instance state of the attribute. 66 | self._state = {} 67 | 68 | #! Whether to ensure the element is inserted last of not. 69 | self._last = last 70 | 71 | def serialize(self, obj=None): 72 | # Serialize the data in the instance state to the representation 73 | # specified (only JSON supported as of now). 74 | data = collections.OrderedDict() 75 | 76 | # Iterate through the non-last declared attributes. 77 | for name, attr in self._attributes.items(): 78 | if attr._last: 79 | # Don't add this yet. 80 | continue 81 | 82 | value = attr.serialize(self) 83 | if value is not None: 84 | data[name] = value 85 | 86 | # Iterate through the last declared attributes. 87 | for name, attr in self._attributes.items(): 88 | if attr._last: 89 | value = attr.serialize(self) 90 | if value is not None: 91 | data[name] = value 92 | 93 | # Return the data dictionary. 94 | return data 95 | 96 | @classmethod 97 | def deserialize(cls, text, instance=None): 98 | if isinstance(text, str): 99 | # Decode the data dictionary from JSON. 100 | text = json.loads(text) 101 | 102 | # Instantiate an instance if one is not provided. 103 | if instance is None: 104 | instance = cls() 105 | 106 | # Iterate through the attributes to rebuild the instance state. 107 | for name, attr in instance._attributes.items(): 108 | value = text.get(name) 109 | value = attr.deserialize(value) 110 | if value: 111 | instance._state[name] = value 112 | 113 | # Return the reconstructed instance. 114 | return instance 115 | 116 | 117 | class Singular(Base): 118 | 119 | def __init__(self, type_, name=None, required=False, *args, **kwargs): 120 | #! The underyling type of the attribute. 121 | self.type = type_ 122 | if isinstance(type_, type): 123 | # Instantiate the type reference with no parameters. 124 | self.type = type_() 125 | 126 | #! The name as the attribute is represented in the serialized form. 127 | #! 128 | #! If left as None; this will be set to the camelCased version of 129 | #! the property name. 130 | self.name = name 131 | 132 | #! Whether this attribute is required. 133 | self.required = required 134 | 135 | # Continue initialization. 136 | super().__init__(*args, **kwargs) 137 | 138 | def __get__(self, instance, owner=None): 139 | if instance is not None: 140 | # Being accessed as an instance; use the instance state. 141 | return instance._state.get(self.name) 142 | 143 | # Return ourself. 144 | return self 145 | 146 | def __set__(self, instance, value): 147 | if instance is not None: 148 | # Being accessed as an instance; use the instance state. 149 | instance._state[self.name] = value 150 | return 151 | 152 | # Continue along with normal behavior. 153 | super().__set__(instance, value) 154 | 155 | def __delete__(self, instance): 156 | if instance is not None: 157 | # Being accessed as an instance; use the instance state. 158 | if self.name in instance._state: 159 | del instance._state[self.name] 160 | return 161 | 162 | # Continue along with normal behavior. 163 | super().__delete__(instance) 164 | 165 | def serialize(self, obj): 166 | if self.name in obj._state: 167 | return self.type.serialize(obj._state[self.name]) 168 | 169 | def deserialize(self, text): 170 | return self.type.deserialize(text) 171 | 172 | 173 | class Complex(Base): 174 | 175 | def __init__(self, type_, name=None, *args, **kwargs): 176 | #! The underyling type of the attribute. 177 | self.type = type_ 178 | 179 | #! The name as the attribute is represented in the serialized form. 180 | #! 181 | #! If left as None; this will be set to the camelCased version of 182 | #! the property name. 183 | self.name = name 184 | 185 | # Continue initialization. 186 | super().__init__(*args, **kwargs) 187 | 188 | def __get__(self, instance, owner=None): 189 | if instance is not None: 190 | if self.name not in instance._state: 191 | # Initialize the type. 192 | instance._state[self.name] = self.type() 193 | 194 | # Being accessed as an instance; use the instance state. 195 | return instance._state[self.name] 196 | 197 | # Return ourself. 198 | return self 199 | 200 | def __set__(self, instance, value): 201 | if instance is not None: 202 | # Being accessed as an instance; bail. 203 | raise AttributeError("can't set attribute") 204 | 205 | # Continue along with normal behavior. 206 | super().__set__(instance, value) 207 | 208 | def __delete__(self, instance): 209 | if instance is not None: 210 | # Being accessed as an instance; bail. 211 | raise AttributeError("can't delete attribute") 212 | 213 | # Continue along with normal behavior. 214 | super().__delete__(instance) 215 | 216 | def serialize(self, obj): 217 | # Grab the data object to serialize. 218 | if self.name in obj._state: 219 | obj = obj._state[self.name] 220 | 221 | else: 222 | # No data to serialize. 223 | return 224 | 225 | # Serialize the data in the instance state to the representation 226 | # specified (only JSON supported as of now). 227 | data = collections.OrderedDict() 228 | for name, attr in obj._attributes.items(): 229 | value = attr.serialize(obj) 230 | if value: 231 | data[name] = value 232 | 233 | # Return the serialized data. 234 | return data 235 | 236 | def deserialize(self, text): 237 | if text is not None: 238 | return super().deserialize(text, instance=self.type()) 239 | 240 | 241 | class BaseList(collections.MutableSequence): 242 | 243 | def __init__(self, type_, name, convert): 244 | # Turn the instance state into states. 245 | self._states = [] 246 | 247 | #! Conversion method. 248 | self.convert = convert 249 | 250 | #! The underyling attribute. 251 | self.type = type_ 252 | if isinstance(type_, type): 253 | # Instantiate the type reference with no parameters. 254 | self.type = type_() 255 | 256 | #! The underyling name of this. 257 | self.name = name 258 | 259 | def insert(self, index, value): 260 | self._states.insert(index, self.convert(value)) 261 | 262 | def __getitem__(self, index): 263 | return self._states[index] 264 | 265 | def __setitem__(self, index, value): 266 | self._states[index] = self.convert(value) 267 | 268 | def __delitem__(self, index): 269 | del self._states[index] 270 | 271 | def __len__(self): 272 | return len(self._states) 273 | 274 | def __contains__(self, value): 275 | return value in self._states 276 | 277 | 278 | class List(Complex): 279 | 280 | def __init__(self, type_, convert=lambda x: x, *args, **kwargs): 281 | # Continue initialization. 282 | super().__init__(None, *args, **kwargs) 283 | 284 | #! The underyling type of the attribute. 285 | self.type = lambda: BaseList(type_, self.name, convert) 286 | 287 | def serialize(self, obj): 288 | # Grab the data object to serialize. 289 | if self.name in obj._state: 290 | obj = obj._state[self.name] 291 | 292 | else: 293 | # No data to serialize. 294 | return 295 | 296 | # Serialize the data in the instance state to the representation 297 | # specified (only JSON supported as of now). 298 | data = [] 299 | for value in obj: 300 | value = obj.type.serialize(value) 301 | if value: 302 | data.append(value) 303 | 304 | # Return the serialized data. 305 | return data 306 | 307 | def deserialize(self, text): 308 | if isinstance(text, str): 309 | # Decode the data dictionary from JSON. 310 | text = json.loads(text) 311 | 312 | if not text: 313 | # Return nothing if we got nothing. 314 | return None 315 | 316 | # Construct an instance. 317 | instance = self.type() 318 | 319 | # Iterate through values and reconstruct the state. 320 | instance.extend(text) 321 | 322 | # Return the constructed instance. 323 | return instance 324 | 325 | 326 | class BaseMultiValue(Base): 327 | 328 | #! A label indicating the attribute's function; e.g., "work" or "home". 329 | type = Singular(String) 330 | 331 | #! A Boolean value indicating the 'primary' or preferred attribute value 332 | #! for this attribute, e.g. the preferred mailing address or 333 | #! primary e-mail address. The primary attribute value 'true' MUST 334 | #! appear no more than once. 335 | primary = Singular(Boolean) 336 | 337 | #! A human readable name, primarily used for display purposes. 338 | display = Singular(String) 339 | 340 | #! The operation to perform on the multi-valued attribute during 341 | #! a PATCH request. The only valid value is "delete", which 342 | #! signifies that this instance should be removed from the Resource. 343 | operation = Singular(String) 344 | 345 | #! The attribute's significant value; e.g., the e-mail address, 346 | #! phone number, etc. Attributes that define a "value" sub-attribute 347 | #! MAY be alternately represented as a collection of primitive types. 348 | value = None 349 | 350 | def serialize(self): 351 | # Serialize the data in the instance state to the representation 352 | # specified (only JSON supported as of now). 353 | data = collections.OrderedDict() 354 | for name, attr in self._attributes.items(): 355 | value = attr.serialize(self) 356 | if value is not None: 357 | data[name] = value 358 | 359 | # Return the serialized data. 360 | return data 361 | 362 | 363 | class MultiValue(List): 364 | 365 | def _convert(self, value): 366 | if type(value) != self.attribute: 367 | obj = self.attribute() 368 | obj.value = value 369 | value = obj 370 | 371 | return value 372 | 373 | def __init__(self, type_=Singular(String), *args, **kwargs): 374 | #! The type of the value attring. 375 | self.attribute = type_ = type( 376 | 'Value', (BaseMultiValue,), {'value': type_}) 377 | 378 | # Continue initialization. 379 | super().__init__(type_, convert=self._convert, *args, **kwargs) 380 | 381 | def serialize(self, obj): 382 | # Grab the data object to serialize. 383 | if self.name in obj._state: 384 | obj = obj._state[self.name] 385 | 386 | else: 387 | # No data to serialize. 388 | return 389 | 390 | # Serialize the data in the instance state to the representation 391 | # specified (only JSON supported as of now). 392 | data = [] 393 | for value in obj: 394 | value = value.serialize() 395 | if value is not None: 396 | data.append(value) 397 | 398 | # Return the serialized data. 399 | return data 400 | 401 | def deserialize(self, text): 402 | if isinstance(text, str): 403 | # Decode the data dictionary from JSON. 404 | text = json.loads(text) 405 | 406 | if not text: 407 | # Return nothing if we got nothing. 408 | return None 409 | 410 | # Construct an instance. 411 | instance = self.type() 412 | 413 | # Iterate through values and reconstruct the state. 414 | for value in text: 415 | instance.append(self.attribute.deserialize(value)) 416 | 417 | # Return the constructed instance. 418 | return instance 419 | -------------------------------------------------------------------------------- /src/scim/schema/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import attributes, types 3 | 4 | 5 | class Metadata(attributes.Base): 6 | """A complex attribute containing resource metadata. 7 | """ 8 | 9 | #! The DateTime the Resource was added to the Service Provider. 10 | created = attributes.Singular(types.DateTime) 11 | 12 | #! The most recent DateTime the details of this Resource were updated at 13 | #! the Service Provider. If this Resource has never been modified since 14 | #! its initial creation, the value MUST be the same as the value of 15 | #! created. 16 | last_modified = attributes.Singular(types.DateTime) 17 | 18 | #! The URI of the resource being returned. 19 | #! 20 | #! This value MUST be the same as the Location HTTP response header. 21 | location = attributes.Singular(types.String) 22 | 23 | #! The version of the Resource being returned. 24 | #! 25 | #! This value must be the same as the ETag HTTP response header. 26 | version = attributes.Singular(types.String) 27 | 28 | #! The names of the attributes to remove during a PATCH operation. 29 | attributes = attributes.List(types.String) 30 | 31 | 32 | class Base(attributes.Base): 33 | """Defines the base SCIM schema (v1.1 § 5.5). 34 | 35 | Contains common attributes that all data models in the SCIM schema have. 36 | """ 37 | 38 | class Meta: 39 | schema = 'urn:scim:schemas:core:1.0' 40 | 41 | #! Unique identifier for the SCIM Resource as defined by the 42 | #! Service Provider. 43 | #! 44 | #! Each representation of the Resource MUST include a non-empty id value. 45 | #! This identifier MUST be unique across the Service Provider's entire 46 | #! set of Resources. It MUST be a stable, non-reassignable identifier 47 | #! that does not change when the same Resource is returned in 48 | #! subsequent requests. 49 | #! 50 | #! The value of the id attribute is always issued by the Service Provider 51 | #! and MUST never be specified by the Service Consumer. 52 | id = attributes.Singular(types.String, required=True) 53 | 54 | #! An identifier for the Resource as defined by the Service Consumer. 55 | #! 56 | #! The externalId may simplify identification of the Resource between 57 | #! Service Consumer and Service provider by allowing the Consumer to 58 | #! refer to the Resource with its own identifier, obviating the need to 59 | #! store a local mapping between the local identifier of the Resource and 60 | #! the identifier used by the Service Provider. 61 | external_id = attributes.Singular(types.String) 62 | 63 | #! A complex attribute containing resource metadata. 64 | meta = attributes.Complex(Metadata, last=True) 65 | -------------------------------------------------------------------------------- /src/scim/schema/tenant.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .core import Base 3 | from . import attributes, types 4 | 5 | 6 | class Tenant(Base): 7 | 8 | class Meta: 9 | schema = 'urn:scim:schemas:extension:tenant:1.0' 10 | 11 | #! The name of the tenant, suitable for display to end-users. 12 | display_name = attributes.Singular(types.String) 13 | 14 | #! Indicates the tenant's preferred written or spoken language. 15 | preferred_language = attributes.Singular(types.String) 16 | 17 | #! Used to indicate the tenant's default location for purposes of 18 | #! localizing items such as currency, date time format, 19 | #! numerical representations, etc. 20 | locale = attributes.Singular(types.String) 21 | 22 | #! The tenant's time zone in the "Olson" timezone database 23 | #! format; e.g.,'America/Los_Angeles'. 24 | timezone = attributes.Singular(types.String) 25 | 26 | #! A Boolean value indicating the tenant's administrative status. 27 | active = attributes.Singular(types.Boolean) 28 | 29 | #! A list of entitlements for the Tenant that represent 30 | # a thing the Tenant has. 31 | entitlements = attributes.MultiValue() 32 | -------------------------------------------------------------------------------- /src/scim/schema/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from dateutil.parser import parse as parse_datetime 3 | 4 | 5 | class Base: 6 | 7 | def serialize(self, value): 8 | return str(value) if value is not None else None 9 | 10 | def deserialize(self, text): 11 | if text is not None: 12 | return text 13 | 14 | 15 | class String(Base): 16 | """A sequence of characters (v1.1 § 3.1.1). 17 | """ 18 | 19 | 20 | class Boolean(Base): 21 | """The literal "true" or "false" (v1.1 § 3.1.2). 22 | """ 23 | 24 | def serialize(self, value): 25 | return value 26 | 27 | def deserialize(self, text): 28 | return text 29 | 30 | 31 | class Decimal(Base): 32 | """A Decimal number with no fractional digits (v1.1 § 3.1.3). 33 | """ 34 | 35 | def deserialize(self, text): 36 | if text is not None: 37 | return float(text) 38 | 39 | 40 | class Integer(Base): 41 | """A Decimal number with no fractional digits (v1.1 § 3.1.4). 42 | """ 43 | 44 | def deserialize(self, text): 45 | if text is not None: 46 | return int(text) 47 | 48 | 49 | class DateTime(Base): 50 | """ 51 | An ISO 8601 formatted (eg. 2008-01-23T04:56:22Z) 52 | date-time (v1.1 § 3.1.5). 53 | """ 54 | 55 | def serialize(self, value): 56 | return value.isoformat() if value is not None else None 57 | 58 | def deserialize(self, text): 59 | if text is not None: 60 | return parse_datetime(text, fuzzy=False) 61 | -------------------------------------------------------------------------------- /src/scim/schema/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .core import Base 3 | from . import attributes, types 4 | 5 | 6 | class Username(attributes.Base): 7 | """The components of the User's real name. 8 | """ 9 | 10 | #! The full name, including all middle names, titles, and suffixes as 11 | #! appropriate, formatted for display (e.g. Ms. Barbara Jane Jensen, III.). 12 | formatted = attributes.Singular(types.String) 13 | 14 | #! The family name of the User, or "Last Name" in 15 | #! most Western languages (e.g. Jensen given the full name Ms. 16 | #! Barbara Jane Jensen, III.). 17 | family_name = attributes.Singular(types.String) 18 | 19 | #! The given name of the User, or "First Name" in most Western 20 | #! languages (e.g. Barbara given the full name 21 | #! Ms. Barbara Jane Jensen, III.). 22 | given_name = attributes.Singular(types.String) 23 | 24 | #! The middle name(s) of the User (e.g. Jane given the full 25 | #! name Ms. Barbara Jane Jensen, III.). 26 | middle_name = attributes.Singular(types.String) 27 | 28 | #! The honorific prefix(es) of the User, or "Title" in most 29 | #! Western languages (e.g. Ms. given the full name 30 | #! Ms. Barbara Jane Jensen, III.). 31 | honorific_prefix = attributes.Singular(types.String) 32 | 33 | #! The honorific suffix(es) of the User, or "Suffix" in most 34 | #! Western languages (e.g. III. given the full name 35 | #! Ms. Barbara Jane Jensen, III.). 36 | honorific_suffix = attributes.Singular(types.String) 37 | 38 | 39 | class User(Base): 40 | """SCIM provides a schema for representing Users (v1.1 § 6). 41 | """ 42 | 43 | #! Unique identifier for the User, typically used by the user to directly 44 | #! authenticate to the service provider. 45 | username = attributes.Singular(types.String, 'userName') 46 | 47 | #! The components of the User's real name. 48 | name = attributes.Complex(Username) 49 | 50 | #! The name of the User, suitable for display to end-users. 51 | display_name = attributes.Singular(types.String) 52 | 53 | #! The casual way to address the user in real life, e.g. "Bob" 54 | #! or "Bobby" instead of "Robert". 55 | nick_name = attributes.Singular(types.String) 56 | 57 | #! A fully qualified URL to a page representing the User's online profile. 58 | profile_url = attributes.Singular(types.String) 59 | 60 | #! The user’s title, such as “Vice President.” 61 | title = attributes.Singular(types.String) 62 | 63 | #! Used to identify the organization to user relationship. 64 | #! Typical values used might be "Contractor", "Employee", 65 | #! "Intern", "Temp", "External", and "Unknown" but any value may be used. 66 | user_type = attributes.Singular(types.String) 67 | 68 | #! Indicates the User's preferred written or spoken language. 69 | preferred_language = attributes.Singular(types.String) 70 | 71 | #! Used to indicate the User's default location for purposes of 72 | #! localizing items such as currency, date time format, 73 | #! numerical representations, etc. 74 | locale = attributes.Singular(types.String) 75 | 76 | #! The User's time zone in the "Olson" timezone database 77 | #! format; e.g.,'America/Los_Angeles'. 78 | timezone = attributes.Singular(types.String) 79 | 80 | #! A Boolean value indicating the User's administrative status. 81 | active = attributes.Singular(types.Boolean) 82 | 83 | #! The User's clear text password. This attribute is intended to be used 84 | #! as a means to specify an initial password when creating a new User or 85 | #! to reset an existing User's password. 86 | #! 87 | #! This value MUST never be returned by a Service Provider in any form. 88 | password = attributes.Singular(types.String) 89 | 90 | #! E-mail addresses for the User. 91 | emails = attributes.MultiValue() 92 | 93 | #! Phone numbers for the User. 94 | phone_numbers = attributes.MultiValue() 95 | 96 | #! Instant messaging address for the User. 97 | ims = attributes.MultiValue() 98 | 99 | #! URL of a photo of the User. 100 | photos = attributes.MultiValue() 101 | 102 | # TODO: addresses 103 | 104 | # TODO: groups 105 | 106 | #! A list of entitlements for the User that represent a thing the User has. 107 | entitlements = attributes.MultiValue() 108 | 109 | #! A list of roles for the User that collectively represent who the 110 | #! User is. 111 | roles = attributes.MultiValue() 112 | 113 | # TODO: x509_certificates 114 | 115 | 116 | class Manager(attributes.Base): 117 | 118 | #! The id of the SCIM resource representing the User's manager. 119 | id = attributes.Singular(types.String, 'managerId') 120 | 121 | #! The URI of the SCIM resource representing the User's manager. 122 | uri = attributes.Singular(types.String, '$ref') 123 | 124 | #! The displayName of the User's manager. 125 | display_name = attributes.Singular(types.String) 126 | 127 | 128 | class EnterpriseUser(User): 129 | """ 130 | Maps to the Enterprise User extension which defines attributes 131 | commonly used in representing users that belong to, or act on behalf 132 | of a business or enterprise (v2.0 § 7). 133 | """ 134 | 135 | class Meta: 136 | schema = 'urn:scim:schemas:extension:enterprise:2.0:User' 137 | 138 | #! Numeric or alphanumeric identifier assigned to a 139 | #! person, typically based on order of hire or association with an 140 | #! organization. 141 | employee_number = attributes.Singular(types.String) 142 | 143 | #! Identifies the name of a cost center. 144 | cost_center = attributes.Singular(types.String) 145 | 146 | #! Identifies the name of an organization. 147 | organization = attributes.Singular(types.String) 148 | 149 | #! Identifies the name of a department. 150 | department = attributes.Singular(types.String) 151 | 152 | #! Identifies the name of a division. 153 | division = attributes.Singular(types.String) 154 | 155 | #! The User's manager. A complex type that optionally allows 156 | #! Service Providers to represent organizational hierarchy by 157 | #! referencing the "id" attribute of another User. 158 | manager = attributes.Complex(Manager) 159 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | from os import path 4 | 5 | # Get the base path. 6 | base = path.join(path.dirname(__file__), '..') 7 | 8 | # Append the source directory to PATH. 9 | sys.path.append(path.join(base, 'src')) 10 | -------------------------------------------------------------------------------- /tests/scim/test_schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | from scim import schema 4 | 5 | 6 | class TestUser: 7 | 8 | def test_serialize(self): 9 | u = schema.User() 10 | u.id = '43568296982626' 11 | u.external_id = '42' 12 | u.meta.created = datetime.now() 13 | u.meta.attributes.append('blue') 14 | u.meta.attributes.append('red') 15 | u.name.given_name = 'Bob' 16 | u.preferred_language = 'en_US' 17 | u.active = False 18 | u.emails.append('bob@example.com') 19 | u.emails[0].primary = False 20 | 21 | d = u.serialize() 22 | 23 | assert d['id'] == u.id 24 | assert d['externalId'] == u.external_id 25 | assert d['active'] == u.active 26 | assert d['name']['givenName'] == u.name.given_name 27 | assert d['preferredLanguage'] == u.preferred_language 28 | assert d['meta']['created'] == u.meta.created.isoformat() 29 | assert len(d['meta']['attributes']) == len(u.meta.attributes) 30 | assert d['meta']['attributes'][0] == u.meta.attributes[0] 31 | assert len(d['emails']) == len(u.emails) 32 | assert d['emails'][0]['value'] == u.emails[0].value 33 | assert d['emails'][0]['primary'] == u.emails[0].primary 34 | assert list(d.keys())[-1] == 'meta' 35 | 36 | def test_deserialize(self): 37 | d = {} 38 | d['id'] = '43568296982626' 39 | d['externalId'] = '42' 40 | d['active'] = 'false' 41 | d['meta'] = {} 42 | d['meta']['created'] = datetime.now().isoformat() 43 | d['meta']['attributes'] = ['blue', 'red'] 44 | d['name'] = {} 45 | d['name']['givenName'] = 'Bob' 46 | d['preferredLanguage'] = 'en_US' 47 | d['emails'] = [{'value': 'bob@example.com', 'primary': 'false'}] 48 | 49 | u = schema.User.deserialize(d) 50 | 51 | assert d['id'] == u.id 52 | assert d['externalId'] == u.external_id 53 | assert d['active'] == u.active 54 | assert d['name']['givenName'] == u.name.given_name 55 | assert d['preferredLanguage'] == u.preferred_language 56 | assert d['meta']['created'] == u.meta.created.isoformat() 57 | assert len(d['meta']['attributes']) == len(u.meta.attributes) 58 | assert d['meta']['attributes'][0] == u.meta.attributes[0] 59 | assert len(d['emails']) == len(u.emails) 60 | assert d['emails'][0]['value'] == u.emails[0].value 61 | assert d['emails'][0]['primary'] == u.emails[0].primary 62 | --------------------------------------------------------------------------------