Working on surfacing credentials via REST.

This commit is contained in:
Michael DeHaan
2013-04-02 14:59:58 -04:00
parent 37cdd31b79
commit b20a29b458
7 changed files with 97 additions and 59 deletions

View File

@@ -34,7 +34,7 @@ import json as python_json
# FIXME: machinery for auto-adding audit trail logs to all CREATE/EDITS # FIXME: machinery for auto-adding audit trail logs to all CREATE/EDITS
class BaseList(generics.ListCreateAPIView): class BaseList(generics.ListCreateAPIView):
def list_permissions_check(self, request, obj=None): def list_permissions_check(self, request, obj=None):
''' determines some early yes/no access decisions, pre-filtering ''' ''' determines some early yes/no access decisions, pre-filtering '''
if request.method == 'GET': if request.method == 'GET':
@@ -50,7 +50,7 @@ class BaseList(generics.ListCreateAPIView):
raise PermissionDenied() raise PermissionDenied()
return True return True
raise exceptions.NotImplementedError raise exceptions.NotImplementedError
def get_queryset(self): def get_queryset(self):
base = self._get_queryset() base = self._get_queryset()
model = self.__class__.model model = self.__class__.model
@@ -121,11 +121,16 @@ class BaseSubList(BaseList):
# save the object through the serializer, reload and returned the saved object deserialized # save the object through the serializer, reload and returned the saved object deserialized
obj = ser.save() obj = ser.save()
ser = self.__class__.serializer_class(obj) ser = self.__class__.serializer_class(obj)
# now make sure we could have already attached the two together. If we could not have, raise an exception # now make sure we could have already attached the two together. If we could not have, raise an exception
# such that the transaction does not commit. # such that the transaction does not commit.
if not self.__class__.parent_model.can_user_attach(request.user, main, obj, self.__class__.relationship): if self.__class__.parent_model != User:
raise PermissionDenied() if not self.__class__.parent_model.can_user_attach(request.user, main, obj, self.__class__.relationship):
raise PermissionDenied()
else:
# FIXME: should generalize this
if not UserHelper.can_user_attach(request.user, main, obj, self.__class__.relationship):
raise PermissionDenied()
return Response(status=status.HTTP_201_CREATED, data=ser.data) return Response(status=status.HTTP_201_CREATED, data=ser.data)
@@ -141,7 +146,7 @@ class BaseSubList(BaseList):
return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_400_BAD_REQUEST)
sub = subs[0] sub = subs[0]
relationship = getattr(main, self.__class__.relationship) relationship = getattr(main, self.__class__.relationship)
if not 'disassociate' in request.DATA: if not 'disassociate' in request.DATA:
if not request.user.is_superuser and not self.__class__.parent_model.can_user_attach(request.user, main, sub, self.__class__.relationship): if not request.user.is_superuser and not self.__class__.parent_model.can_user_attach(request.user, main, sub, self.__class__.relationship):
raise PermissionDenied() raise PermissionDenied()
@@ -215,14 +220,14 @@ class BaseDetail(generics.RetrieveUpdateDestroyAPIView):
pass pass
class VariableBaseDetail(BaseDetail): class VariableBaseDetail(BaseDetail):
''' '''
an object that is always 1 to 1 with the foreign key of another object an object that is always 1 to 1 with the foreign key of another object
and does not have it's own key, such as HostVariableDetail and does not have it's own key, such as HostVariableDetail
''' '''
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
raise PermissionDenied() raise PermissionDenied()
def delete_permissions_check(self, request, obj): def delete_permissions_check(self, request, obj):
raise PermissionDenied() raise PermissionDenied()
@@ -285,4 +290,4 @@ class VariableBaseDetail(BaseDetail):
if not has_permission: if not has_permission:
raise PermissionDenied() raise PermissionDenied()
return Response(status=status.HTTP_200_OK, data=python_json.loads(this_object.data)) return Response(status=status.HTTP_200_OK, data=python_json.loads(this_object.data))

View File

@@ -126,6 +126,13 @@ class UserHelper(object):
matching_orgs = obj.organizations.filter(admins__in = [user]).count() matching_orgs = obj.organizations.filter(admins__in = [user]).count()
return matching_orgs return matching_orgs
@classmethod
def can_user_attach(cls, user, obj, sub_obj, relationship_type):
if type(sub_obj) != User:
if not sub_obj.can_user_read(user, sub_obj):
return False
rc = cls.can_user_administrate(user, obj)
return rc
class PrimordialModel(models.Model): class PrimordialModel(models.Model):
''' '''
@@ -186,11 +193,11 @@ class CommonModel(PrimordialModel):
name = models.CharField(max_length=512, unique=True) name = models.CharField(max_length=512, unique=True)
class CommonModelNameNotUnique(PrimordialModel): class CommonModelNameNotUnique(PrimordialModel):
''' a base model where the name is not unique ''' ''' a base model where the name is not unique '''
class Meta: class Meta:
abstract = True abstract = True
name = models.CharField(max_length=512, unique=False) name = models.CharField(max_length=512, unique=False)
class Tag(models.Model): class Tag(models.Model):
@@ -290,7 +297,7 @@ class Inventory(CommonModel):
unique_together = (("name", "organization"),) unique_together = (("name", "organization"),)
organization = models.ForeignKey(Organization, null=False, related_name='inventories') organization = models.ForeignKey(Organization, null=False, related_name='inventories')
def get_absolute_url(self): def get_absolute_url(self):
import lib.urls import lib.urls
return reverse(lib.urls.views_InventoryDetail, args=(self.pk,)) return reverse(lib.urls.views_InventoryDetail, args=(self.pk,))
@@ -308,16 +315,16 @@ class Inventory(CommonModel):
user = user, user = user,
permission_type__in = allowed permission_type__in = allowed
).count() ).count()
result = (by_org_admin + by_team_permission + by_user_permission) result = (by_org_admin + by_team_permission + by_user_permission)
return result > 0 return result > 0
@classmethod @classmethod
def _has_any_inventory_permission_types(cls, user, allowed): def _has_any_inventory_permission_types(cls, user, allowed):
''' '''
rather than checking for a permission on a specific inventory, return whether we have rather than checking for a permission on a specific inventory, return whether we have
permissions on any inventory. This is primarily used to decide if the user can create permissions on any inventory. This is primarily used to decide if the user can create
host or group objects host or group objects
''' '''
if user.is_superuser: if user.is_superuser:
@@ -332,7 +339,7 @@ class Inventory(CommonModel):
by_user_permission = user.permissions.filter( by_user_permission = user.permissions.filter(
permission_type__in = allowed permission_type__in = allowed
).count() ).count()
result = (by_org_admin + by_team_permission + by_user_permission) result = (by_org_admin + by_team_permission + by_user_permission)
return result > 0 return result > 0
@@ -380,7 +387,7 @@ class Host(CommonModelNameNotUnique):
class Meta: class Meta:
app_label = 'main' app_label = 'main'
unique_together = (("name", "inventory"),) unique_together = (("name", "inventory"),)
variable_data = models.OneToOneField('VariableData', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='host') variable_data = models.OneToOneField('VariableData', null=True, default=None, blank=True, on_delete=SET_NULL, related_name='host')
inventory = models.ForeignKey('Inventory', null=False, related_name='hosts') inventory = models.ForeignKey('Inventory', null=False, related_name='hosts')
@@ -390,7 +397,7 @@ class Host(CommonModelNameNotUnique):
@classmethod @classmethod
def can_user_read(cls, user, obj): def can_user_read(cls, user, obj):
return Inventory.can_user_read(user, obj.inventory) return Inventory.can_user_read(user, obj.inventory)
@classmethod @classmethod
def can_user_add(cls, user, data): def can_user_add(cls, user, data):
if not 'inventory' in data: if not 'inventory' in data:
@@ -432,7 +439,7 @@ class Group(CommonModelNameNotUnique):
inventory = Inventory.objects.get(pk=data['inventory']) inventory = Inventory.objects.get(pk=data['inventory'])
return Inventory._has_permission_types(user, inventory, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) return Inventory._has_permission_types(user, inventory, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE)
@classmethod @classmethod
def can_user_administrate(cls, user, obj): def can_user_administrate(cls, user, obj):
# here this controls whether the user can attach subgroups # here this controls whether the user can attach subgroups
@@ -511,7 +518,7 @@ class Credential(CommonModel):
# #
# STAGE 3: # STAGE 3:
# #
# MICHAEL: modify ansible/ansible-playbook such that # MICHAEL: modify ansible/ansible-playbook such that
# if ANSIBLE_PASSWORD or ANSIBLE_SUDO_PASSWORD is set # if ANSIBLE_PASSWORD or ANSIBLE_SUDO_PASSWORD is set
# you do not have to use --ask-pass and --ask-sudo-pass, so we don't have to do interactive # you do not have to use --ask-pass and --ask-sudo-pass, so we don't have to do interactive
# stuff with that. # stuff with that.
@@ -520,10 +527,24 @@ class Credential(CommonModel):
ssh_key_data = models.TextField(blank=True, default='') ssh_key_data = models.TextField(blank=True, default='')
ssh_key_unlock = models.CharField(blank=True, default='', max_length=1024) ssh_key_unlock = models.CharField(blank=True, default='', max_length=1024)
default_username = models.CharField(blank=True, default='', max_length=1024) default_username = models.CharField(blank=True, default='', max_length=1024)
ssh_password = models.CharField(blank=True, default='', max_length=1024) ssh_password = models.CharField(blank=True, default='', max_length=1024)
sudo_password = models.CharField(blank=True, default='', max_length=1024) sudo_password = models.CharField(blank=True, default='', max_length=1024)
@classmethod
def can_user_read(cls, user, obj):
''' a user can be read if they are on the same team or can be administrated '''
if user.is_superuser:
return True
if user == obj.user:
return True
if Organizations.filter(admins__in = [ user ], users__in = [ obj.user ]).count():
return True
return False
def get_absolute_url(self):
import lib.urls
return reverse(lib.urls.views_CredentialsDetail, args=(self.pk,))
class Team(CommonModel): class Team(CommonModel):
''' '''
@@ -571,7 +592,7 @@ class Team(CommonModel):
@classmethod @classmethod
def can_user_delete(cls, user, obj): def can_user_delete(cls, user, obj):
return cls.can_user_administrate(user, obj) return cls.can_user_administrate(user, obj)
class Project(CommonModel): class Project(CommonModel):
''' '''
A project represents a playbook git repo that can access a set of inventories A project represents a playbook git repo that can access a set of inventories
@@ -632,7 +653,7 @@ class Permission(CommonModelNameNotUnique):
# for example, user A on inventory X has write permissions (PERM_INVENTORY_WRITE) # for example, user A on inventory X has write permissions (PERM_INVENTORY_WRITE)
# team C on inventory X has read permissions (PERM_INVENTORY_READ) # team C on inventory X has read permissions (PERM_INVENTORY_READ)
# team C on inventory X and project Y has launch permissions (PERM_INVENTORY_DEPLOY) # team C on inventory X and project Y has launch permissions (PERM_INVENTORY_DEPLOY)
# team C on inventory X and project Z has dry run permissions (PERM_INVENTORY_CHECK) # team C on inventory X and project Z has dry run permissions (PERM_INVENTORY_CHECK)
# #
# basically for launching, permissions can be awarded to the whole inventory source or just the inventory source # basically for launching, permissions can be awarded to the whole inventory source or just the inventory source
# in context of a given project. # in context of a given project.
@@ -640,14 +661,14 @@ class Permission(CommonModelNameNotUnique):
# the project parameter is not used when dealing with READ, WRITE, or ADMIN permissions. # the project parameter is not used when dealing with READ, WRITE, or ADMIN permissions.
permission_type = models.CharField(max_length=64, choices=PERMISSION_TYPE_CHOICES) permission_type = models.CharField(max_length=64, choices=PERMISSION_TYPE_CHOICES)
def __unicode__(self): def __unicode__(self):
return unicode("Permission(name=%s,ON(user=%s,team=%s),FOR(project=%s,inventory=%s,type=%s))" % ( return unicode("Permission(name=%s,ON(user=%s,team=%s),FOR(project=%s,inventory=%s,type=%s))" % (
self.name, self.name,
self.user, self.user,
self.team, self.team,
self.project, self.project,
self.inventory, self.inventory,
self.permission_type self.permission_type
)) ))

View File

@@ -146,7 +146,7 @@ class CredentialSerializer(BaseSerializer):
model = Credential model = Credential
fields = ( fields = (
'url', 'id', 'related', 'name', 'description', 'creation_date', 'url', 'id', 'related', 'name', 'description', 'creation_date',
'ssh_key_path', 'ssh_key_data', 'ssh_key_unlock', 'ssh_password', 'sudo_password', 'default_username', 'ssh_key_data', 'ssh_key_unlock', 'ssh_password', 'sudo_password',
'user', 'team' 'user', 'team'
) )

View File

@@ -285,7 +285,7 @@ class ProjectsTest(BaseTest):
new_credentials = dict( new_credentials = dict(
name = 'credential', name = 'credential',
project = Project.objects.all()[0].pk, project = Project.objects.all()[0].pk,
ssh_key_path = 'foo', default_username = 'foo',
ssh_key_data = 'bar', ssh_key_data = 'bar',
ssh_key_unlock = 'baz', ssh_key_unlock = 'baz',
ssh_password = 'narf', ssh_password = 'narf',

View File

@@ -88,8 +88,12 @@ class RunLaunchJobTest(BaseCeleryTest):
launch_job_status = self.launch_job.start() launch_job_status = self.launch_job.start()
self.assertEqual(launch_job_status.status, 'pending') self.assertEqual(launch_job_status.status, 'pending')
launch_job_status = LaunchJobStatus.objects.get(pk=launch_job_status.pk) launch_job_status = LaunchJobStatus.objects.get(pk=launch_job_status.pk)
#print 'stdout:', launch_job_status.result_stdout print 'stdout:', launch_job_status.result_stdout
#print 'stderr:', launch_job_status.result_stderr print 'stderr:', launch_job_status.result_stderr
print launch_job_status.status
print settings.DATABASES
self.assertEqual(launch_job_status.status, 'successful') self.assertEqual(launch_job_status.status, 'successful')
self.assertTrue(launch_job_status.result_stdout) self.assertTrue(launch_job_status.result_stdout)
launch_job_status_events = launch_job_status.launch_job_status_events.all() launch_job_status_events = launch_job_status.launch_job_status_events.all()

View File

@@ -39,9 +39,9 @@ class OrganizationsList(BaseList):
# I can see the organizations if: # I can see the organizations if:
# I am a superuser # I am a superuser
# I am an admin of the organization # I am an admin of the organization
# I am a member of the organization # I am a member of the organization
def _get_queryset(self): def _get_queryset(self):
''' I can see organizations when I am a superuser, or I am an admin or user in that organization ''' ''' I can see organizations when I am a superuser, or I am an admin or user in that organization '''
base = Organization.objects base = Organization.objects
@@ -78,7 +78,7 @@ class OrganizationsAuditTrailList(BaseSubList):
class OrganizationsUsersList(BaseSubList): class OrganizationsUsersList(BaseSubList):
model = User model = User
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
@@ -95,7 +95,7 @@ class OrganizationsUsersList(BaseSubList):
return User.objects.filter(organizations__in = [ organization ]) return User.objects.filter(organizations__in = [ organization ])
class OrganizationsAdminsList(BaseSubList): class OrganizationsAdminsList(BaseSubList):
model = User model = User
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
@@ -112,7 +112,7 @@ class OrganizationsAdminsList(BaseSubList):
return User.objects.filter(admin_of_organizations__in = [ organization ]) return User.objects.filter(admin_of_organizations__in = [ organization ])
class OrganizationsProjectsList(BaseSubList): class OrganizationsProjectsList(BaseSubList):
model = Project model = Project
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
@@ -120,7 +120,7 @@ class OrganizationsProjectsList(BaseSubList):
relationship = 'projects' # " " relationship = 'projects' # " "
postable = True postable = True
inject_primary_key_on_post_as = 'organization' inject_primary_key_on_post_as = 'organization'
def _get_queryset(self): def _get_queryset(self):
''' to list projects in the organization, I must be a superuser or org admin ''' ''' to list projects in the organization, I must be a superuser or org admin '''
organization = Organization.objects.get(pk=self.kwargs['pk']) organization = Organization.objects.get(pk=self.kwargs['pk'])
@@ -129,7 +129,7 @@ class OrganizationsProjectsList(BaseSubList):
return Project.objects.filter(organizations__in = [ organization ]) return Project.objects.filter(organizations__in = [ organization ])
class OrganizationsTagsList(BaseSubList): class OrganizationsTagsList(BaseSubList):
model = Tag model = Tag
serializer_class = TagSerializer serializer_class = TagSerializer
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
@@ -147,7 +147,7 @@ class OrganizationsTagsList(BaseSubList):
return Tag.objects.filter(organization_by_tag__in = [ organization ]) return Tag.objects.filter(organization_by_tag__in = [ organization ])
class OrganizationsTeamsList(BaseSubList): class OrganizationsTeamsList(BaseSubList):
model = Team model = Team
serializer_class = TeamSerializer serializer_class = TeamSerializer
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
@@ -174,7 +174,7 @@ class TeamsList(BaseList):
# I am a superuser # I am a superuser
# I am an admin of the organization that the team is # I am an admin of the organization that the team is
# I am on that team # I am on that team
def _get_queryset(self): def _get_queryset(self):
''' I can see organizations when I am a superuser, or I am an admin or user in that organization ''' ''' I can see organizations when I am a superuser, or I am an admin or user in that organization '''
base = Team.objects base = Team.objects
@@ -193,7 +193,7 @@ class TeamsDetail(BaseDetail):
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
class TeamsUsersList(BaseSubList): class TeamsUsersList(BaseSubList):
model = User model = User
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
@@ -224,7 +224,7 @@ class ProjectsList(BaseList):
# I am a superuser # I am a superuser
# I am an admin of the organization that contains the project # I am an admin of the organization that contains the project
# I am a member of a team that also contains the project # I am a member of a team that also contains the project
def _get_queryset(self): def _get_queryset(self):
''' I can see organizations when I am a superuser, or I am an admin or user in that organization ''' ''' I can see organizations when I am a superuser, or I am an admin or user in that organization '''
base = Project.objects base = Project.objects
@@ -279,15 +279,15 @@ class UsersList(BaseList):
user = User.objects.get(pk=pk) user = User.objects.get(pk=pk)
user.set_password(password) user.set_password(password)
user.save() user.save()
return result return result
def _get_queryset(self): def _get_queryset(self):
''' I can see user records when I'm a superuser, I'm that user, I'm their org admin, or I'm on a team with that user ''' ''' I can see user records when I'm a superuser, I'm that user, I'm their org admin, or I'm on a team with that user '''
base = User.objects base = User.objects
if self.request.user.is_superuser: if self.request.user.is_superuser:
return base.all() return base.all()
mine = base.filter(pk = self.request.user.pk).distinct() mine = base.filter(pk = self.request.user.pk).distinct()
admin_of = base.filter(organizations__in = self.request.user.admin_of_organizations.all()).distinct() admin_of = base.filter(organizations__in = self.request.user.admin_of_organizations.all()).distinct()
same_team = base.filter(teams__in = self.request.user.teams.all()).distinct() same_team = base.filter(teams__in = self.request.user.teams.all()).distinct()
return mine | admin_of | same_team return mine | admin_of | same_team
@@ -362,7 +362,7 @@ class UsersOrganizationsList(BaseSubList):
parent_model = User parent_model = User
relationship = 'organizations' relationship = 'organizations'
postable = False postable = False
def _get_queryset(self): def _get_queryset(self):
user = User.objects.get(pk=self.kwargs['pk']) user = User.objects.get(pk=self.kwargs['pk'])
if not UserHelper.can_user_administrate(self.request.user, user): if not UserHelper.can_user_administrate(self.request.user, user):
@@ -391,7 +391,7 @@ class UsersDetail(BaseDetail):
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
def put_filter(self, request, *args, **kwargs): def put_filter(self, request, *args, **kwargs):
''' make sure non-read-only fields that can only be edited by admins, are only edited by admins ''' ''' make sure non-read-only fields that can only be edited by admins, are only edited by admins '''
obj = User.objects.get(pk=kwargs['pk']) obj = User.objects.get(pk=kwargs['pk'])
if EditHelper.illegal_changes(request, obj, UserHelper): if EditHelper.illegal_changes(request, obj, UserHelper):
raise PermissionDenied() raise PermissionDenied()
@@ -400,6 +400,13 @@ class UsersDetail(BaseDetail):
obj.save() obj.save()
request.DATA.pop('password') request.DATA.pop('password')
class CredentialsDetail(BaseDetail):
model = Credential
serializer_class = CredentialSerializer
permission_classes = (CustomRbac,)
class InventoryList(BaseList): class InventoryList(BaseList):
model = Inventory model = Inventory
@@ -438,9 +445,9 @@ class HostsList(BaseList):
permission_classes = (CustomRbac,) permission_classes = (CustomRbac,)
def _get_queryset(self): def _get_queryset(self):
''' '''
I can see hosts when: I can see hosts when:
I'm a superuser, I'm a superuser,
or an organization admin of an inventory they are in or an organization admin of an inventory they are in
or when I have allowing read permissions via a user or team on an inventory they are in or when I have allowing read permissions via a user or team on an inventory they are in
''' '''
@@ -524,7 +531,7 @@ class GroupsChildrenList(BaseSubList):
def _get_queryset(self): def _get_queryset(self):
# FIXME: this is the mostly the same as GroupsList, share code similar to how done with Host and Group objects. # FIXME: this is the mostly the same as GroupsList, share code similar to how done with Host and Group objects.
parent = Group.objects.get(pk=self.kwargs['pk']) parent = Group.objects.get(pk=self.kwargs['pk'])
# FIXME: verify read permissions on this object are still required at a higher level # FIXME: verify read permissions on this object are still required at a higher level

View File

@@ -80,6 +80,9 @@ views_VariableDetail = views.VariableDetail.as_view()
# tags service # tags service
views_TagsDetail = views.TagsDetail.as_view() views_TagsDetail = views.TagsDetail.as_view()
# credentials service
views_CredentialsDetail = views.CredentialsDetail.as_view()
urlpatterns = patterns('', urlpatterns = patterns('',
@@ -107,7 +110,7 @@ urlpatterns = patterns('',
url(r'^api/v1/projects/$', views_ProjectsList), url(r'^api/v1/projects/$', views_ProjectsList),
url(r'^api/v1/projects/(?P<pk>[0-9]+)/$', views_ProjectsDetail), url(r'^api/v1/projects/(?P<pk>[0-9]+)/$', views_ProjectsDetail),
url(r'^api/v1/projects/(?P<pk>[0-9]+)/organizations/$', views_ProjectsOrganizationsList), url(r'^api/v1/projects/(?P<pk>[0-9]+)/organizations/$', views_ProjectsOrganizationsList),
# audit trail service # audit trail service
# api/v1/audit_trails/ # api/v1/audit_trails/
# api/v1/audit_trails/N/ # api/v1/audit_trails/N/
@@ -119,7 +122,7 @@ urlpatterns = patterns('',
url(r'^api/v1/teams/(?P<pk>[0-9]+)/$', views_TeamsDetail), url(r'^api/v1/teams/(?P<pk>[0-9]+)/$', views_TeamsDetail),
url(r'^api/v1/teams/(?P<pk>[0-9]+)/users/$', views_TeamsUsersList), url(r'^api/v1/teams/(?P<pk>[0-9]+)/users/$', views_TeamsUsersList),
# api/v1/teams/N/ # api/v1/teams/N/
# api/v1/teams/N/users/ # api/v1/teams/N/users/
# inventory service # inventory service
@@ -155,9 +158,7 @@ urlpatterns = patterns('',
# ... and tag relations on all resources # ... and tag relations on all resources
# credentials services # credentials services
# ... users url(r'^api/v1/credentials/(?P<pk>[0-9]+)/$', views_CredentialsDetail),
# ... teams
# ... projects (?)
# permissions services # permissions services
# ... users # ... users