From 131f7f2d869d394ac6e942c1135529033f1e0ca2 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Fri, 20 Apr 2012 10:42:45 +0200 Subject: scim v0.1 --- asgard/settings.d/10-apps.conf | 3 +- asgard/venv.conf | 1 + coip/apps/invitation/models.py | 68 ++++++++++++++++++- coip/apps/invitation/views.py | 7 +- coip/apps/membership/models.py | 62 ++++++++++++++++- coip/apps/name/models.py | 10 ++- coip/apps/resource/__init__.py | 1 + coip/apps/resource/admin.py | 4 ++ coip/apps/resource/models.py | 90 +++++++++++++++++++++++++ coip/apps/scim/__init__.py | 86 ++++++++++++++++++++++++ coip/apps/scim/schema.py | 57 ++++++++++++++++ coip/apps/scim/urls.py | 16 +++++ coip/apps/scim/views.py | 144 ++++++++++++++++++++++++++++++++++++++++ coip/apps/userprofile/models.py | 8 ++- coip/settings.py | 4 +- coip/urls.py | 1 + templates/base.html | 2 +- templates/djangosaml2/wayf.html | 3 +- 18 files changed, 549 insertions(+), 18 deletions(-) create mode 100644 coip/apps/resource/__init__.py create mode 100644 coip/apps/resource/admin.py create mode 100644 coip/apps/resource/models.py create mode 100644 coip/apps/scim/__init__.py create mode 100644 coip/apps/scim/schema.py create mode 100644 coip/apps/scim/urls.py create mode 100644 coip/apps/scim/views.py diff --git a/asgard/settings.d/10-apps.conf b/asgard/settings.d/10-apps.conf index a5a05f5..b59cf45 100644 --- a/asgard/settings.d/10-apps.conf +++ b/asgard/settings.d/10-apps.conf @@ -9,5 +9,6 @@ INSTALLED_APPS += [ 'actstream', 'coip.apps.opensocial', 'coip.apps.activitystreams', - 'coip.apps.saml2' + 'coip.apps.saml2', + 'coip.apps.resource' ] diff --git a/asgard/venv.conf b/asgard/venv.conf index 712d141..f62501f 100644 --- a/asgard/venv.conf +++ b/asgard/venv.conf @@ -26,3 +26,4 @@ PIL==1.1.7 django-activity-stream==0.3.9 python-memcached hg+https://bitbucket.org/leifj/djangosaml2 +iso8601 diff --git a/coip/apps/invitation/models.py b/coip/apps/invitation/models.py index fabc145..22a4492 100644 --- a/coip/apps/invitation/models.py +++ b/coip/apps/invitation/models.py @@ -5,10 +5,20 @@ Created on Jun 23, 2010 ''' from django.db import models from django.contrib.auth.models import User -from coip.apps.name.models import Name +from coip.apps.name.models import Name, lookup from django.core.mail import send_mail import logging from coip.settings import PREFIX_URL, NOREPLY +from coip.apps.scim.schema import scim_simple_attribute, ScimAttribute,\ + scim_reference_attribute +from coip.apps.scim import types +from coip.apps.resource.models import object_for_uuid +from coip.apps.auth.utils import nonce +from django.dispatch.dispatcher import receiver +from django.db.models.signals import post_save +from actstream.signals import action +from django.core.exceptions import ObjectDoesNotExist +from iso8601 import iso8601 class Invitation(models.Model): ''' @@ -26,8 +36,8 @@ class Invitation(models.Model): def __unicode__(self): return "%s invited to %s by %s" % (self.email,self.name,self.inviter) - def send_email(self,request): - pinviter = request.user.get_profile() + def send_email(self): + pinviter = self.inviter.get_profile() send_mail('Invitation to join \'%s\'' % (self.name.shortname()), ''' %s (%s) has invited you to join \'%s\': @@ -48,3 +58,55 @@ To view information about \'%s\' open this link in your browser: fail_silently=False) return +try: + from coip.apps.resource.models import resources + resources.register(Invitation) +except ImportError: + pass + +@receiver(post_save,sender=Invitation) +def auto_create_resource(sender,**kwargs): + if kwargs['created']: + invitation = kwargs['instance'] + user = invitation.inviter + invitation.send_email() + action.send(user,verb='invited',target=invitation.name,action_object=invitation) + +class InvitationSchema(): + URI = "uri:mace:swami.se:scim:schema:coip:1.0" + ATTRIBUTES = ('mail','externalId','groupId','message','expires','userId') + + externalId = scim_simple_attribute('id') + mail = scim_simple_attribute('email') + groupId = scim_reference_attribute('name') + message = scim_simple_attribute('message') + expires = scim_simple_attribute('expires') + userId = scim_reference_attribute('inviter') + + def create(self,model,d): + user = None + if d.has_key('userId'): + user = object_for_uuid(d['userId']) + elif d.has_key('userName'): + try: + user = User.objects.get(username=d['userName']) + except ObjectDoesNotExist: + pass + + if user == None: + raise Exception("unspecified user") + + name = None + if d.has_key('groupId'): + name = object_for_uuid(d['groupId']) + elif d.has_key('groupName'): + name = lookup(d['groupName']) + + if name == None: + raise Exception("unspecified group") + + expires = iso8601.parse_date(d['expires']) + invitation,created = Invitation.objects.get_or_create(inviter=user,nonce=nonce(),name=name,email=d['mail'],expires=expires) + return invitation + +types.register(Invitation,"Invitations",[InvitationSchema()]) \ No newline at end of file diff --git a/coip/apps/invitation/views.py b/coip/apps/invitation/views.py index 8e09ddc..2d11764 100644 --- a/coip/apps/invitation/views.py +++ b/coip/apps/invitation/views.py @@ -28,15 +28,12 @@ def invite(request,id): form = InvitationForm(request.POST,instance=invitation) if form.is_valid(): invitation = form.save() - invitation.send_email(request) return HttpResponseRedirect("/name/id/%d" % (name.id)) else: exp = datetime.datetime.now()+datetime.timedelta(days=1) invitation=Invitation(message="Please consider joining my group!",expires=exp.strftime("%Y-%m-%d")) form = InvitationForm(instance=invitation); - action.send(user,verb='invited',target=name,action_object=invitation) - return respond_to(request,{'text/html': 'apps/invitation/edit.html'},{'form': form,'name': name,'formtitle': 'Invite someone to join %s' % (name.short),'submitname': 'Invite User'}) @login_required @@ -74,6 +71,4 @@ def resend(request,id): action.send(request.user,verb='renewed invitation to',target=name,action_object=invitation) invitation.send_email() - return HttpResponseRedirect("/name/id/%d" % (name.id)) - - + return HttpResponseRedirect("/name/id/%d" % (name.id)) \ No newline at end of file diff --git a/coip/apps/membership/models.py b/coip/apps/membership/models.py index 9aec54f..6e5dbe5 100644 --- a/coip/apps/membership/models.py +++ b/coip/apps/membership/models.py @@ -13,6 +13,8 @@ from coip.settings import NOREPLY from coip.extensions.templatetags.userdisplay import userdisplay from coip.apps.userprofile.models import UserProfile from actstream.signals import action +from coip.apps.scim import types +import logging STATUS = {UserProfile.INTERNAL:'internal', UserProfile.ENTITY:'entity', @@ -115,4 +117,62 @@ def remove_member(name,member_name,actor=None): def has_member(name,member_name): return Membership.objects.filter(name=name,user=member_name) -tagging.register(Membership) \ No newline at end of file +tagging.register(Membership) + +from coip.apps.scim.schema import scim_simple_attribute, ScimAttribute +from coip.apps.resource.models import object_for_uuid + +class GroupSchema(): + URI = 'urn:scim:schemas:core:1.0' + ATTRIBUTES = ('externalId','displayName','members','parentId') + + externalId = scim_simple_attribute('url') + displayName = scim_simple_attribute('display') + + class MembersAttribute(ScimAttribute): + + def __get__(self,o,objtype=None): + return [ + { + 'display': userdisplay(m.user), + 'value': m.user.uuid + } for m in o.memberships.filter(hidden=False).all() + ] + + def __set__(self,o,v): + o.memberships = [] + for i in v: + member = object_for_uuid(v['value']) + add_member(o,member) + + def __delete__(self,o): + o.memberships.clear() + + def remove(self,o,v): + member = object_for_uuid(v['value']) + remove_member(o,member.username,actor="scim") + + def add(self,o,v): + member = object_for_uuid(v['value']) + add_member(o,member.username,actor="scim") + + class ParentAttribute(ScimAttribute): + def __get__(self,o,objtype=None): + if o.parent: + return o.parent.uuid + else: + return None + + members = MembersAttribute() + parentId = ParentAttribute() + +class UserSchema(): + URI = 'urn:scim:schemas:core:1.0' + ATTRIBUTES = ('externalId','userName') + + externalId = scim_simple_attribute('username') + userName = scim_simple_attribute('username') + + +types.register(Name, "Groups", [GroupSchema()]) +types.register(User,"Users",[UserSchema()]) diff --git a/coip/apps/name/models.py b/coip/apps/name/models.py index 519248b..eecfa2a 100644 --- a/coip/apps/name/models.py +++ b/coip/apps/name/models.py @@ -10,7 +10,9 @@ from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.db.models.signals import pre_save import logging -from coip.settings import PREFIX_URL +from django.conf import settings +from coip.apps import resource +from coip.apps.resource.models import resources class Attribute(models.Model): name = models.CharField(unique=True,max_length=255) @@ -108,13 +110,13 @@ class Name(models.Model): return str def url(self): - return "%s/name/%s" % (PREFIX_URL,self.display_str_url()) + return "%s/name/%s" % (settings.PREFIX_URL,self.display_str_url()) def uri(self): if self.mode() == FMT_URN: return self.display else: # implement more format as needed - return "%s/name/%s" % (PREFIX_URL,self.display) + return "%s/name/%s" % (settings.PREFIX_URL,self.display) def summary(self): return {'name': self.display, 'url': self.url(), 'short': self.short} @@ -298,3 +300,5 @@ def lookup(name,autocreate=False): def attribute(a): Attribute.objects.get_or_create(name=a) + +resources.register(Name) \ No newline at end of file diff --git a/coip/apps/resource/__init__.py b/coip/apps/resource/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/coip/apps/resource/__init__.py @@ -0,0 +1 @@ + diff --git a/coip/apps/resource/admin.py b/coip/apps/resource/admin.py new file mode 100644 index 0000000..69f2e01 --- /dev/null +++ b/coip/apps/resource/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from coip.apps.resource.models import Resource + +admin.site.register(Resource) \ No newline at end of file diff --git a/coip/apps/resource/models.py b/coip/apps/resource/models.py new file mode 100644 index 0000000..7282ae6 --- /dev/null +++ b/coip/apps/resource/models.py @@ -0,0 +1,90 @@ +''' +Created on Apr 10, 2012 + +@author: leifj +''' + +from django.db import models +from django.db.models.fields import DateTimeField +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import generic +from django_extensions.db.fields import UUIDField +from django.dispatch.dispatcher import receiver +from django.db.models.signals import post_save, pre_delete +from django.core.exceptions import ObjectDoesNotExist +from django.db.models.base import ModelBase + +class NotAModel(Exception): + pass + +class RecursiveRegistration(Exception): + pass + +class UninitializedObject(Exception): + pass + +class Resource(models.Model): + uuid = UUIDField(unique=True) + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + content_object = generic.GenericForeignKey('content_type', 'object_id') + lastupdated = DateTimeField(auto_now=True) + timecreated = DateTimeField(auto_now_add=True) + + def __unicode__(self): + return self.uuid + +def object_for_uuid(uuid): + try: + r = Resource.objects.get(uuid=uuid) + return r.content_object + except ObjectDoesNotExist: + return None + +def uuid_for_object(o): + if not o.id: + raise UninitializedObject("Can't obtain uuid for non-persisted objects") + + typ = ContentType.objects.get_for_model(o) + r,cr = Resource.objects.get_or_create(object_id=o.id,content_type=typ) + return r.uuid + +def add_resource(o): + typ = ContentType.objects.get_for_model(o) + r,cr = Resource.objects.get_or_create(object_id=o.id,content_type=typ) + return r.uuid + +def delete_resource(o): + typ = ContentType.objects.get_for_model(o) + try: + r = Resource.objects.get(object_id=o.id,content_type=typ) + r.delete() + except ObjectDoesNotExist: + pass + +class ResourceClassRegistry(): + def __init__(self): + self._registry = [] + + def register(self,model,addField=True): + if not isinstance(model,ModelBase): + raise NotAModel('%s does not appear to be a model!' % model) + if not model in self._registry: + if addField: + setattr(model,'uuid',property(lambda x: uuid_for_object(x))) + self._registry.append(model) + +resources = ResourceClassRegistry() + +def register(model): + resources.register(model) + +@receiver(post_save) +def auto_create_resource(sender,**kwargs): + if sender in resources._registry and kwargs['created']: + add_resource(kwargs['instance']) + +@receiver(pre_delete) +def auto_delete_resource(sender,**kwargs): + if sender in resources._registry: + delete_resource(kwargs['instance']) diff --git a/coip/apps/scim/__init__.py b/coip/apps/scim/__init__.py new file mode 100644 index 0000000..0520e65 --- /dev/null +++ b/coip/apps/scim/__init__.py @@ -0,0 +1,86 @@ +import logging + +class NotRegistered(Exception): + pass + +class NotAvailable(Exception): + pass + +class ObjectHandler(object): + def __init__(self,model,schemas): + self.model = model + self.schemas = schemas + + def serialize(self,o): + d = {'meta': {},'schemas':[]} + for schema in self.schemas: + if schema.URI == 'urn:scim:schemas:core:1.0': + d.update(self.as_dict(schema,o)) + else: + d.update({schema.URI: self.as_dict(schema,o)}) + d['schemas'].append(schema.URI) + return d + + def create(self,d): + for schema in self.schemas: + if schema.URI in d['schemas']: + data = d + if schema.URI != 'urn:scim:schemas:core:1.0': + data = d[schema.URI] + try: + return schema.create(self.model,data) + except NotAvailable: + pass + raise NotAvailable("No way to create this object") + + def as_dict(self,schema,o): + d = dict([(a,schema.__class__.__dict__[a].__get__(o)) for a in schema.ATTRIBUTES]) + logging.debug(d) + return d + + def update(self,o,d,replace=False): + meta_attributes = d['meta'].get('attributes',{}) + for schema in self.schemas: + if schema in d['schemas']: + data = d + if schema.URI != 'urn:scim:schemas:core:1.0': + data = d[schema] + + for a in schema.ATTRIBUTES: + v = data[a] + t = type(v) + p = getattr(schema,a) + if a in meta_attributes: + if not data.has_key(a): + p.__delete__(o) + else: + p.__set__(o,v) ## replace + else: #add / remove + if t is dict: #merge + p.__set__(o,p.__get__(o).update(v)) + elif (t is list or t is tuple) and not t is str: + for i in v: + if type(i) is dict: + if i.get('operation',None) == 'delete': + p.remove(o,i) + else: + p.update(o,i) + else: # no nested lists + p.add(o,i) + else: + p.__set__(o,v) # simple type + +class ObjectTypeRegistry(object): + def __init__(self): + self._registry = {} + + def register(self,model,prefix,schemas): + if prefix == None: + prefix = "%ss" % model.__name__ + #for schema in schemas: + # for attr in schema.ATTRIBUTES: + # setattr(model,attr,schema.__dict__['externalId']) + + self._registry[prefix] = ObjectHandler(model,schemas) + +types = ObjectTypeRegistry() \ No newline at end of file diff --git a/coip/apps/scim/schema.py b/coip/apps/scim/schema.py new file mode 100644 index 0000000..5290cd5 --- /dev/null +++ b/coip/apps/scim/schema.py @@ -0,0 +1,57 @@ +''' +Created on Apr 12, 2012 + +@author: leifj +''' +from coip.apps.scim import NotAvailable + +class ScimAttribute(object): + def __get__(self,o,objtype=None): + raise NotAvailable() + + def __set__(self,o,v): + raise NotAvailable() + + def __delete__(self,o): + raise NotAvailable() + + def add(self,o,v): + raise NotAvailable() + + def remove(self,o,v): + raise NotAvailable() + +class scim_simple_attribute(ScimAttribute): + + def __init__(self,attr): + self._attr = attr + + def __get__(self,o,objtype=None): + a = getattr(o,self._attr) + if hasattr(a,'__call__'): + return "%s" % a() + else: + return "%s" % a + + def __set__(self,o,v): + a = getattr(o,self._attr) + if hasattr(a,'__call__'): + a(v) + else: + setattr(o,self._attr,v) + + def __delete__(self,o): + a = getattr(o,self._attr) + if not hasattr(a,'__call__'): + setattr(o,self._attr,None) + +class scim_reference_attribute(ScimAttribute): + def __init__(self,attr): + self._attr = attr + + def __get__(self,o,objtype=None): + a = getattr(o,self._attr) + if a != None: + return a.uuid + else: + return None \ No newline at end of file diff --git a/coip/apps/scim/urls.py b/coip/apps/scim/urls.py new file mode 100644 index 0000000..2802586 --- /dev/null +++ b/coip/apps/scim/urls.py @@ -0,0 +1,16 @@ +''' +Created on Apr 10, 2012 + +@author: leifj +''' + +from django.conf.urls.defaults import patterns, url + +# my/name/scim/v1/Groups/ + +urlpatterns = patterns('coip.apps.scim.views', + url(r'^(?P[^\/]+)/?$',view='scim_v1'), + url(r'^(?P[^\/]+)/?(?P[^\/]+)/?$',view='scim_v1'), +) + +#(?:^(?P[0-9]+)/)? \ No newline at end of file diff --git a/coip/apps/scim/views.py b/coip/apps/scim/views.py new file mode 100644 index 0000000..b299262 --- /dev/null +++ b/coip/apps/scim/views.py @@ -0,0 +1,144 @@ +''' +Created on Apr 10, 2012 + +@author: leifj +''' +from django.http import HttpResponseNotFound, HttpResponseBadRequest,\ + HttpResponse +from coip.apps.resource.models import object_for_uuid + +from django.utils import simplejson +from coip.apps.name.models import Name, lookup +from uuid import UUID +from coip.apps.scim import types +import logging + +def get_handler_for_prefix(prefix): + if not prefix in types._registry.keys(): + return None + return types._registry[prefix] + +def scim_response(d,status=200): + content = None + if d != None: + content = simplejson.dumps(d) + r = HttpResponse(content=content,status=status,content_type='application/json') + if not d.has_key('meta'): + d['meta'] = {} + location = d['meta'].get('location',None) + if location is not None: + r['Location'] = location + + return r + +def _resolve_name(nid): + if nid == None: + return None + + if '/' in nid: + return lookup(nid) + + try: + pk = int(nid) + return Name.objects.get(id=pk) + except ValueError: + pass + + try: + return object_for_uuid(str(UUID(nid))) + except ValueError: + pass + + return None + +def scim_v1_get(request,name,handler,uuid): + o = object_for_uuid(uuid) + if o == None: + return HttpResponseNotFound + if not isinstance(o, handler.model): + return HttpResponseBadRequest("%s does not resolve to an object of type %s" % (uuid,handler.model.__name__)) + d = handler.serialize(o) + #logging.debug(d) + d['meta']['location'] = request.build_absolute_uri() + return scim_response(d) + +def scim_v1_post(request,name,handler): + # authorize request based on name + d = simplejson.loads(request.raw_post_data) + + o = handler.create(d) + + d = handler.serialize(o) + d['meta']['location'] = request.build_absolute_uri() + return scim_response(d,201) + +def scim_v1_delete(request,name,handler,uuid): + o = object_for_uuid(uuid) + if o == None: + return HttpResponseNotFound + if not isinstance(o, handler.model): + return HttpResponseBadRequest("%s does not resolve to an object of type %s" % (uuid,handler.model.__name__)) + o.delete() + + return scim_response(200) + +def scim_v1_put(request,name,handler,uuid): + o = object_for_uuid(uuid) + if o == None: + return HttpResponseNotFound + if not isinstance(o,handler.model): + return HttpResponseBadRequest("%s does not resolve to an object of type %s" % (uuid,handler.model.__name__)) + + d = simplejson.loads(request.raw_post_data) + handler.update(o,d,replace=True) + o.save() + + d = handler.serialize(o) + d['meta']['location'] = request.build_absolute_uri() + return scim_response(d,200) + +def scim_v1_patch(request,name,handler,uuid): + o = object_for_uuid(uuid) + if o == None: + return HttpResponseNotFound() + if not isinstance(o, handler.model): + return HttpResponseBadRequest("%s does not resolve to an object of type %s" % (uuid,handler.model.__name__)) + + d = simplejson.loads(request.raw_post_data) + handler.update(o,d,replace=False) + o.save() + + return scim_response(204) + +def scim_v1(request,nid=None,prefix='Groups',uuid=None): + name = _resolve_name(nid) + handler = get_handler_for_prefix(prefix) + if handler == None: + return HttpResponseNotFound("Unknown SCIM resource type %s" % prefix) + + if request.method == 'GET': + return scim_v1_get(request,name,handler,uuid) + + if request.method == 'POST': + #if name == None: + # return HttpResponseNotFound("No such name") + if uuid != None: + return HttpResponseBadRequest("POSTing to resource is not allowed. Use PUT to update a resource.") + return scim_v1_post(request,name,handler) + + if request.method == 'DELETE': + if uuid == None: + return HttpResponseBadRequest("Missing resource") + return scim_v1_delete(request,name,handler,uuid) + + if request.method == 'PATCH': + if uuid == None: + return HttpResponseBadRequest("Missing resource") + return scim_v1_patch(request,name,handler,uuid) + + if request.method == 'PUT': + if uuid == None: + return HttpResponseBadRequest("Missing resource") + return scim_v1_put(request,name,handler,uuid) + + return HttpResponseBadRequest() \ No newline at end of file diff --git a/coip/apps/userprofile/models.py b/coip/apps/userprofile/models.py index 315097d..6d1dd7f 100644 --- a/coip/apps/userprofile/models.py +++ b/coip/apps/userprofile/models.py @@ -48,4 +48,10 @@ class UserProfile(models.Model): @receiver(post_save,sender=User) def _create_profile(sender,**kwargs): user = kwargs['instance'] - UserProfile.objects.get_or_create(user=user) \ No newline at end of file + UserProfile.objects.get_or_create(user=user) + +try: + from coip.apps.resource.models import resources + resources.register(User) +except ImportError: + pass \ No newline at end of file diff --git a/coip/settings.py b/coip/settings.py index a4c7bef..5bb8a99 100644 --- a/coip/settings.py +++ b/coip/settings.py @@ -120,7 +120,9 @@ INSTALLED_APPS = ( 'actstream', 'coip.apps.opensocial', 'coip.apps.activitystreams', - 'coip.apps.saml2' + 'coip.apps.saml2', + 'coip.apps.resource', + 'coip.apps.scim' ) OAUTH_REALM_KEY_NAME = 'http://coip-test.sunet.se' diff --git a/coip/urls.py b/coip/urls.py index 36c65ea..59051fb 100644 --- a/coip/urls.py +++ b/coip/urls.py @@ -71,6 +71,7 @@ urlpatterns = patterns('', # APIs (r'^api/activitystreams/', include('coip.apps.activitystreams.urls')), (r'^api/opensocial/', include('coip.apps.opensocial.urls')), + (r'^api/scim/v1/', include('coip.apps.scim.urls')), (r'^api/hello/?', 'coip.apps.name.views.hello'), (r'^oauth2/', include('django_oauth2_lite.urls')), (r'^saml2/aa/', include('coip.apps.saml2.urls')), diff --git a/templates/base.html b/templates/base.html index 5944a3d..2c32c79 100644 --- a/templates/base.html +++ b/templates/base.html @@ -84,7 +84,7 @@ {% else %} - + {% endif %} diff --git a/templates/djangosaml2/wayf.html b/templates/djangosaml2/wayf.html index d2b29ca..91b6f35 100644 --- a/templates/djangosaml2/wayf.html +++ b/templates/djangosaml2/wayf.html @@ -2,7 +2,8 @@ {% block headline %}Login{% endblock %} {% block title %}COIP{% endblock %} {% block main %} -

Login!

+

Authentication Required

+

In order to proceed you need to identify yourself to the group service...

Please select your Identity Provider from the following list: