diff --git a/CHANGELOG.md b/CHANGELOG.md index f3bac4bcfc4..b62cfc183a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,13 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Polyshape editing method has been improved. You can redraw part of shape instead of points cloning. - Unified shortcut (Esc) for close any mode instead of different shortcuts (Alt+N, Alt+G, Alt+M etc.). - Dump file contains information about data source (e.g. video name, archive name, ...) +- Update requests library due to https://nvd.nist.gov/vuln/detail/CVE-2018-18074 +- Per task/job permissions to create/access/change/delete tasks and annotations ### Fixed - Performance bottleneck has been fixed during you create new objects (draw, copy, merge etc). - Label UI elements aren't updated after changelabel. - Attribute annotation mode can use invalid shape position after resize or move shapes. - ## [0.2.0] - 2018-09-28 ### Added - New annotation shapes: polygons, polylines, points diff --git a/cvat/apps/authentication/__init__.py b/cvat/apps/authentication/__init__.py index a7b92720ff0..4921b1df39a 100644 --- a/cvat/apps/authentication/__init__.py +++ b/cvat/apps/authentication/__init__.py @@ -5,3 +5,13 @@ default_app_config = 'cvat.apps.authentication.apps.AuthenticationConfig' +from enum import Enum + +class AUTH_ROLE(Enum): + ADMIN = 'admin' + USER = 'user' + ANNOTATOR = 'annotator' + OBSERVER = 'observer' + + def __str__(self): + return self.value diff --git a/cvat/apps/authentication/admin.py b/cvat/apps/authentication/admin.py index af8dfc47525..3267ca6871e 100644 --- a/cvat/apps/authentication/admin.py +++ b/cvat/apps/authentication/admin.py @@ -4,6 +4,24 @@ # SPDX-License-Identifier: MIT from django.contrib import admin +from django.contrib.auth.models import Group, User +from django.contrib.auth.admin import GroupAdmin, UserAdmin +from django.utils.translation import ugettext_lazy as _ -# Register your models here. +class CustomUserAdmin(UserAdmin): + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', + 'groups',)}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + ) +class CustomGroupAdmin(GroupAdmin): + fieldsets = ((None, {'fields': ('name',)}),) + + +admin.site.unregister(User) +admin.site.unregister(Group) +admin.site.register(User, CustomUserAdmin) +admin.site.register(Group, CustomGroupAdmin) \ No newline at end of file diff --git a/cvat/apps/authentication/apps.py b/cvat/apps/authentication/apps.py index c0e41e420d6..c6f9e549461 100644 --- a/cvat/apps/authentication/apps.py +++ b/cvat/apps/authentication/apps.py @@ -4,20 +4,11 @@ # SPDX-License-Identifier: MIT from django.apps import AppConfig -from django.db.models.signals import post_migrate, post_save -from .settings.authentication import DJANGO_AUTH_TYPE class AuthenticationConfig(AppConfig): name = 'cvat.apps.authentication' def ready(self): - from . import signals - from django.contrib.auth.models import User + from .auth import register_signals - post_migrate.connect(signals.create_groups) - - if DJANGO_AUTH_TYPE == 'SIMPLE': - post_save.connect(signals.create_user, sender=User, dispatch_uid="create_user") - - import django_auth_ldap.backend - django_auth_ldap.backend.populate_user.connect(signals.update_ldap_groups) + register_signals() diff --git a/cvat/apps/authentication/auth.py b/cvat/apps/authentication/auth.py new file mode 100644 index 00000000000..0da83216e8e --- /dev/null +++ b/cvat/apps/authentication/auth.py @@ -0,0 +1,80 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import os +from django.conf import settings +import rules +from . import AUTH_ROLE + +def register_signals(): + from django.db.models.signals import post_migrate, post_save + from django.contrib.auth.models import User, Group + + def create_groups(sender, **kwargs): + for role in AUTH_ROLE: + db_group, _ = Group.objects.get_or_create(name=role) + db_group.save() + + post_migrate.connect(create_groups, weak=False) + + if settings.DJANGO_AUTH_TYPE == 'BASIC': + from .auth_basic import create_user + + post_save.connect(create_user, sender=User) + elif settings.DJANGO_AUTH_TYPE == 'LDAP': + import django_auth_ldap.backend + from .auth_ldap import create_user + + django_auth_ldap.backend.populate_user.connect(create_user) + +# AUTH PREDICATES +has_admin_role = rules.is_group_member(str(AUTH_ROLE.ADMIN)) +has_user_role = rules.is_group_member(str(AUTH_ROLE.USER)) +has_annotator_role = rules.is_group_member(str(AUTH_ROLE.ANNOTATOR)) +has_observer_role = rules.is_group_member(str(AUTH_ROLE.OBSERVER)) + +@rules.predicate +def is_task_owner(db_user, db_task): + # If owner is None (null) the task can be accessed/changed/deleted + # only by admin. At the moment each task has an owner. + return db_task.owner == db_user + +@rules.predicate +def is_task_assignee(db_user, db_task): + return db_task.assignee == db_user + +@rules.predicate +def is_task_annotator(db_user, db_task): + from functools import reduce + + db_segments = list(db_task.segment_set.prefetch_related('job_set__assignee').all()) + return any([is_job_annotator(db_user, db_job) + for db_segment in db_segments for db_job in db_segment.job_set.all()]) + +@rules.predicate +def is_job_owner(db_user, db_job): + return is_task_owner(db_user, db_job.segment.task) + +@rules.predicate +def is_job_annotator(db_user, db_job): + db_task = db_job.segment.task + # A job can be annotated by any user if the task's assignee is None. + has_rights = db_task.assignee is None or is_task_assignee(db_user, db_task) + if db_job.assignee is not None: + has_rights |= (db_user == db_job.assignee) + + return has_rights + +# AUTH PERMISSIONS RULES +rules.add_perm('engine.task.create', has_admin_role | has_user_role) +rules.add_perm('engine.task.access', has_admin_role | has_observer_role | + is_task_owner | is_task_annotator) +rules.add_perm('engine.task.change', has_admin_role | is_task_owner | + is_task_assignee) +rules.add_perm('engine.task.delete', has_admin_role | is_task_owner) + +rules.add_perm('engine.job.access', has_admin_role | has_observer_role | + is_job_owner | is_job_annotator) +rules.add_perm('engine.job.change', has_admin_role | is_job_owner | + is_job_annotator) diff --git a/cvat/apps/authentication/auth_basic.py b/cvat/apps/authentication/auth_basic.py new file mode 100644 index 00000000000..936628e7fe8 --- /dev/null +++ b/cvat/apps/authentication/auth_basic.py @@ -0,0 +1,12 @@ +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT +from . import AUTH_ROLE +from django.conf import settings + +def create_user(sender, instance, created, **kwargs): + from django.contrib.auth.models import Group + + if instance.is_superuser and instance.is_staff: + db_group = Group.objects.get(name=AUTH_ROLE.ADMIN) + instance.groups.add(db_group) diff --git a/cvat/apps/authentication/auth_ldap.py b/cvat/apps/authentication/auth_ldap.py new file mode 100644 index 00000000000..821a99ba716 --- /dev/null +++ b/cvat/apps/authentication/auth_ldap.py @@ -0,0 +1,29 @@ + +# Copyright (C) 2018 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from django.conf import settings +from . import AUTH_ROLE + +AUTH_LDAP_GROUPS = { + AUTH_ROLE.ADMIN: settings.AUTH_LDAP_ADMIN_GROUPS, + AUTH_ROLE.ANNOTATOR: settings.AUTH_LDAP_ANNOTATOR_GROUPS, + AUTH_ROLE.USER: settings.AUTH_LDAP_USER_GROUPS, + AUTH_ROLE.OBSERVER: settings.AUTH_LDAP_OBSERVER_GROUPS +} + +def create_user(sender, user=None, ldap_user=None, **kwargs): + from django.contrib.auth.models import Group + user_groups = [] + for role in AUTH_ROLE: + db_group = Group.objects.get(name=role) + + for ldap_group in AUTH_LDAP_GROUPS[role]: + if ldap_group.lower() in ldap_user.group_dns: + user_groups.append(db_group) + if role == AUTH_ROLE.ADMIN: + user.is_staff = user.is_superuser = True + + user.groups.set(user_groups) + user.save() diff --git a/cvat/apps/authentication/decorators.py b/cvat/apps/authentication/decorators.py index 3ef9d2631ec..dc0b107fee9 100644 --- a/cvat/apps/authentication/decorators.py +++ b/cvat/apps/authentication/decorators.py @@ -3,16 +3,16 @@ # # SPDX-License-Identifier: MIT +from functools import wraps +from urllib.parse import urlparse from django.contrib.auth import REDIRECT_FIELD_NAME from django.shortcuts import resolve_url, reverse from django.http import JsonResponse -from urllib.parse import urlparse from django.contrib.auth.views import redirect_to_login - -from functools import wraps from django.conf import settings -def login_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None, redirect_methods=['GET']): +def login_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, + login_url=None, redirect_methods=['GET']): def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): diff --git a/cvat/apps/authentication/settings/__init__.py b/cvat/apps/authentication/settings/__init__.py deleted file mode 100644 index d8e62e54b35..00000000000 --- a/cvat/apps/authentication/settings/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - diff --git a/cvat/apps/authentication/settings/auth_ldap.py b/cvat/apps/authentication/settings/auth_ldap.py deleted file mode 100644 index 01021060e10..00000000000 --- a/cvat/apps/authentication/settings/auth_ldap.py +++ /dev/null @@ -1,56 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.conf import settings -import ldap -from django_auth_ldap.config import LDAPSearch, NestedActiveDirectoryGroupType - -# Baseline configuration. -settings.AUTH_LDAP_SERVER_URI = "" - -# Credentials for LDAP server -settings.AUTH_LDAP_BIND_DN = "" -settings.AUTH_LDAP_BIND_PASSWORD = "" - -# Set up basic user search -settings.AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com", - ldap.SCOPE_SUBTREE, "(sAMAccountName=%(user)s)") - -# Set up the basic group parameters. -settings.AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", - ldap.SCOPE_SUBTREE, "(objectClass=group)") -settings.AUTH_LDAP_GROUP_TYPE = NestedActiveDirectoryGroupType() - -# # Simple group restrictions -settings.AUTH_LDAP_REQUIRE_GROUP = "cn=cvat,ou=Groups,dc=example,dc=com" - -# Populate the Django user from the LDAP directory. -settings.AUTH_LDAP_USER_ATTR_MAP = { - "first_name": "givenName", - "last_name": "sn", - "email": "mail", -} - -settings.AUTH_LDAP_ALWAYS_UPDATE_USER = True - -# Cache group memberships for an hour to minimize LDAP traffic -settings.AUTH_LDAP_CACHE_GROUPS = True -settings.AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 -settings.AUTH_LDAP_AUTHORIZE_ALL_USERS = True - -# Keep ModelBackend around for per-user permissions and maybe a local -# superuser. -settings.AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend') - -AUTH_LDAP_ADMIN_GROUPS = [ - "cn=cvat_admins,ou=Groups,dc=example,dc=com" -] - -AUTH_LDAP_DATA_ANNOTATORS_GROUPS = [ -] - -AUTH_LDAP_DEVELOPER_GROUPS = [ - "cn=cvat_users,ou=Groups,dc=example,dc=com" -] diff --git a/cvat/apps/authentication/settings/auth_simple.py b/cvat/apps/authentication/settings/auth_simple.py deleted file mode 100644 index 41a18c98f12..00000000000 --- a/cvat/apps/authentication/settings/auth_simple.py +++ /dev/null @@ -1,8 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -# Specify groups that new users will have -AUTH_SIMPLE_DEFAULT_GROUPS = [] - diff --git a/cvat/apps/authentication/settings/authentication.py b/cvat/apps/authentication/settings/authentication.py deleted file mode 100644 index 3948d0eeab7..00000000000 --- a/cvat/apps/authentication/settings/authentication.py +++ /dev/null @@ -1,58 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.conf import settings -import os - -settings.LOGIN_URL = 'login' -settings.LOGIN_REDIRECT_URL = '/' - -settings.AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', -] - -AUTH_LDAP_DEVELOPER_GROUPS = [] -AUTH_LDAP_DATA_ANNOTATORS_GROUPS = [] -AUTH_LDAP_ADMIN_GROUPS = [] - -DJANGO_AUTH_TYPE = 'LDAP' if os.environ.get('DJANGO_AUTH_TYPE', '') == 'LDAP' else 'SIMPLE' - -if DJANGO_AUTH_TYPE == 'LDAP': - from .auth_ldap import * -else: - from .auth_simple import * - -# Definition of CVAT groups with permissions for task and annotation objects -# Annotator - can modify annotation for task, but cannot add, change and delete tasks -# Developer - can create tasks and modify (delete) owned tasks and any actions with annotation -# Admin - can any actions for task and annotation, can login to admin area and manage groups and users -cvat_groups_definition = { - 'user': { - 'description': '', - 'permissions': { - 'task': ['view', 'add', 'change', 'delete'], - 'annotation': ['view', 'change'], - }, - 'ldap_groups': AUTH_LDAP_DEVELOPER_GROUPS, - }, - - 'annotator': { - 'description': '', - 'permissions': { - 'task': ['view'], - 'annotation': ['view', 'change'], - }, - 'ldap_groups': AUTH_LDAP_DATA_ANNOTATORS_GROUPS, - }, - - 'admin': { - 'description': '', - 'permissions': { - 'task': ['view', 'add', 'change', 'delete'], - 'annotation': ['view', 'change'], - }, - 'ldap_groups': AUTH_LDAP_ADMIN_GROUPS, - }, -} diff --git a/cvat/apps/authentication/signals.py b/cvat/apps/authentication/signals.py deleted file mode 100644 index 2dfc89dc6cd..00000000000 --- a/cvat/apps/authentication/signals.py +++ /dev/null @@ -1,62 +0,0 @@ - -# Copyright (C) 2018 Intel Corporation -# -# SPDX-License-Identifier: MIT - -from django.db import models - -from django.conf import settings -from .settings import authentication -from django.contrib.auth.models import User, Group - -def setup_group_permissions(group): - from cvat.apps.engine.models import Task - from django.contrib.auth.models import Permission - from django.contrib.contenttypes.models import ContentType - - def append_permissions_for_model(model): - content_type = ContentType.objects.get_for_model(model) - for perm_target, actions in authentication.cvat_groups_definition[group.name]['permissions'].items(): - for action in actions: - codename = '{}_{}'.format(action, perm_target) - try: - perm = Permission.objects.get(codename=codename, content_type=content_type) - group_permissions.append(perm) - except: - pass - group_permissions = [] - append_permissions_for_model(Task) - - group.permissions.set(group_permissions) - group.save() - -def create_groups(sender, **kwargs): - for cvat_role, _ in authentication.cvat_groups_definition.items(): - Group.objects.get_or_create(name=cvat_role) - -def update_ldap_groups(sender, user=None, ldap_user=None, **kwargs): - user_groups = [] - for cvat_role, role_settings in authentication.cvat_groups_definition.items(): - group_instance, _ = Group.objects.get_or_create(name=cvat_role) - setup_group_permissions(group_instance) - - for ldap_group in role_settings['ldap_groups']: - if ldap_group.lower() in ldap_user.group_dns: - user_groups.append(group_instance) - - user.save() - user.groups.set(user_groups) - user.is_staff = user.is_superuser = user.groups.filter(name='admin').exists() - -def create_user(sender, instance, created, **kwargs): - if instance.is_superuser and instance.is_staff: - admin_group, _ = Group.objects.get_or_create(name='admin') - admin_group.user_set.add(instance) - - if created: - for cvat_role, _ in authentication.cvat_groups_definition.items(): - group_instance, _ = Group.objects.get_or_create(name=cvat_role) - setup_group_permissions(group_instance) - - if cvat_role in authentication.AUTH_SIMPLE_DEFAULT_GROUPS: - instance.groups.add(group_instance) diff --git a/cvat/apps/authentication/templates/login.html b/cvat/apps/authentication/templates/login.html index 128a8ccb849..855031f7322 100644 --- a/cvat/apps/authentication/templates/login.html +++ b/cvat/apps/authentication/templates/login.html @@ -23,5 +23,7 @@

Login

{% endblock %} {% block note%} -

Have not registered yet? Register here.

+ {% autoescape off %} + {{ note }} + {% endautoescape %} {% endblock %} \ No newline at end of file diff --git a/cvat/apps/authentication/templates/login_ldap.html b/cvat/apps/authentication/templates/login_ldap.html deleted file mode 100644 index 8cba181db5f..00000000000 --- a/cvat/apps/authentication/templates/login_ldap.html +++ /dev/null @@ -1,27 +0,0 @@ - -{% extends "auth_base.html" %} - -{% block title %}Login{% endblock %} - -{% block content %} -

Login

- {% if form.errors %} - Your username and password didn't match. Please try again. - {% endif %} -
- {% csrf_token %} - {% for field in form %} - {{ field }} - {% endfor %} - - -
-{% endblock %} - -{% block note %} - {% include "note.html" %} -{% endblock %} diff --git a/cvat/apps/authentication/templates/note.html b/cvat/apps/authentication/templates/note.html deleted file mode 100644 index ba2fea92248..00000000000 --- a/cvat/apps/authentication/templates/note.html +++ /dev/null @@ -1,7 +0,0 @@ - -

-

\ No newline at end of file diff --git a/cvat/apps/authentication/urls.py b/cvat/apps/authentication/urls.py index d1227171905..e05d734035f 100644 --- a/cvat/apps/authentication/urls.py +++ b/cvat/apps/authentication/urls.py @@ -4,17 +4,20 @@ # SPDX-License-Identifier: MIT from django.urls import path -import os - from django.contrib.auth import views as auth_views +from django.conf import settings + from . import forms from . import views -from .settings.authentication import DJANGO_AUTH_TYPE - -login_page = 'login{}.html'.format('_ldap' if DJANGO_AUTH_TYPE == 'LDAP' else '') urlpatterns = [ - path('login', auth_views.LoginView.as_view(form_class=forms.AuthForm, template_name=login_page), name='login'), + path('login', auth_views.LoginView.as_view(form_class=forms.AuthForm, + template_name='login.html', extra_context={'note': settings.AUTH_LOGIN_NOTE}), + name='login'), path('logout', auth_views.LogoutView.as_view(next_page='login'), name='logout'), - path('register', views.register_user, name='register'), ] + +if settings.DJANGO_AUTH_TYPE == 'BASIC': + urlpatterns += [ + path('register', views.register_user, name='register'), + ] diff --git a/cvat/apps/authentication/views.py b/cvat/apps/authentication/views.py index 2964cf4b82a..c8effb07c3d 100644 --- a/cvat/apps/authentication/views.py +++ b/cvat/apps/authentication/views.py @@ -3,13 +3,12 @@ # # SPDX-License-Identifier: MIT -from django.shortcuts import render -from django.contrib.auth.views import LoginView -from django.http import HttpResponseRedirect +from django.shortcuts import render, redirect +from django.conf import settings +from django.contrib.auth import login, authenticate + from . import forms -from django.contrib.auth import login, authenticate -from django.shortcuts import render, redirect def register_user(request): if request.method == 'POST': @@ -20,7 +19,7 @@ def register_user(request): raw_password = form.cleaned_data.get('password1') user = authenticate(username=username, password=raw_password) login(request, user) - return redirect('/') + return redirect(settings.LOGIN_REDIRECT_URL) else: form = forms.NewUserForm() return render(request, 'register.html', {'form': form}) diff --git a/cvat/apps/dashboard/views.py b/cvat/apps/dashboard/views.py index ea695ce0cb6..0341cc4dc9e 100644 --- a/cvat/apps/dashboard/views.py +++ b/cvat/apps/dashboard/views.py @@ -7,7 +7,6 @@ from django.shortcuts import redirect from django.shortcuts import render from django.conf import settings -from django.contrib.auth.decorators import permission_required from cvat.apps.authentication.decorators import login_required from cvat.apps.engine.models import Task as TaskModel, Job as JobModel @@ -40,7 +39,6 @@ def ScanNode(directory): return result @login_required -@permission_required('engine.add_task', raise_exception=True) def JsTreeView(request): node_id = None if 'id' in request.GET: @@ -57,7 +55,6 @@ def JsTreeView(request): @login_required -@permission_required('engine.view_task', raise_exception=True) def DashboardView(request): query_name = request.GET['search'] if 'search' in request.GET else None query_job = int(request.GET['jid']) if 'jid' in request.GET and request.GET['jid'].isdigit() else None @@ -70,6 +67,9 @@ def DashboardView(request): if query_name is not None: task_list = list(filter(lambda x: query_name.lower() in x.name.lower(), task_list)) + task_list = list(filter(lambda task: request.user.has_perm( + 'engine.task.access', task), task_list)) + return render(request, 'dashboard/dashboard.html', { 'data': task_list, 'max_upload_size': settings.LOCAL_LOAD_MAX_FILES_SIZE, diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index a0969f10f84..9cc6599c71b 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -4,17 +4,27 @@ # SPDX-License-Identifier: MIT from django.contrib import admin -from .models import Task, Segment, Label, AttributeSpec +from .models import Task, Segment, Job, Label, AttributeSpec + +class JobInline(admin.TabularInline): + model = Job + can_delete = False + + # Don't show extra lines to add an object + def has_add_permission(self, request, object=None): + return False class SegmentInline(admin.TabularInline): model = Segment + show_change_link = True readonly_fields = ('start_frame', 'stop_frame') can_delete = False - # Don't show on admin index page + # Don't show extra lines to add an object def has_add_permission(self, request, object=None): return False + class AttributeSpecInline(admin.TabularInline): model = AttributeSpec extra = 0 @@ -35,14 +45,23 @@ def has_module_permission(self, request): AttributeSpecInline ] +class SegmentAdmin(admin.ModelAdmin): + # Don't show on admin index page + def has_module_permission(self, request): + return False + + inlines = [ + JobInline + ] class TaskAdmin(admin.ModelAdmin): date_hierarchy = 'updated_date' readonly_fields = ('size', 'path', 'created_date', 'updated_date', 'overlap', 'flipped') - list_display = ('name', 'mode', 'owner', 'created_date', 'updated_date') + list_display = ('name', 'mode', 'owner', 'assignee', 'created_date', 'updated_date') search_fields = ('name', 'mode', 'owner__username', 'owner__first_name', - 'owner__last_name', 'owner__email') + 'owner__last_name', 'owner__email', 'assignee__username', 'assignee__first_name', + 'assignee__last_name') inlines = [ SegmentInline, LabelInline @@ -54,4 +73,5 @@ def has_add_permission(self, request): admin.site.register(Task, TaskAdmin) +admin.site.register(Segment, SegmentAdmin) admin.site.register(Label, LabelAdmin) diff --git a/cvat/apps/engine/annotation.py b/cvat/apps/engine/annotation.py index a0f6a34ae74..12540742a6e 100644 --- a/cvat/apps/engine/annotation.py +++ b/cvat/apps/engine/annotation.py @@ -38,7 +38,7 @@ def dump(tid, data_format, scheme, host): def check(tid): """ - Check that potentialy long operation 'dump' is completed. + Check that potentially long operation 'dump' is completed. Return the status as json/dictionary object. """ queue = django_rq.get_queue('default') diff --git a/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py b/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py new file mode 100644 index 00000000000..bc735269eed --- /dev/null +++ b/cvat/apps/engine/migrations/0013_auth_no_default_permissions.py @@ -0,0 +1,118 @@ +# Generated by Django 2.0.9 on 2018-11-07 12:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0012_auto_20181025_1618'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='attributespec', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='job', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='label', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledboxattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledpointsattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledpolygonattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='labeledpolylineattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='objectpathattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='segment', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='task', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedbox', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedboxattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpoints', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpointsattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolygon', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolygonattributeval', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolyline', + options={'default_permissions': ()}, + ), + migrations.AlterModelOptions( + name='trackedpolylineattributeval', + options={'default_permissions': ()}, + ), + migrations.RenameField( + model_name='job', + old_name='annotator', + new_name='assignee', + ), + migrations.AddField( + model_name='task', + name='assignee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assignees', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='task', + name='owner', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owners', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='job', + name='assignee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='task', + name='bug_tracker', + field=models.CharField(blank=True, default='', max_length=2000), + ), + migrations.AlterField( + model_name='task', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owners', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index 1aaa5198a37..ebc81338363 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -39,8 +39,11 @@ class Task(models.Model): size = models.PositiveIntegerField() path = models.CharField(max_length=256) mode = models.CharField(max_length=32) - owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) - bug_tracker = models.CharField(max_length=2000, default="") + owner = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL, related_name="owners") + assignee = models.ForeignKey(User, null=True, blank=True, + on_delete=models.SET_NULL, related_name="assignees") + bug_tracker = models.CharField(max_length=2000, blank=True, default="") created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now_add=True) overlap = models.PositiveIntegerField(default=0) @@ -51,11 +54,7 @@ class Task(models.Model): # Extend default permission model class Meta: - permissions = ( - ("view_task", "Can see available tasks"), - ("view_annotation", "Can see annotation for the task"), - ("change_annotation", "Can modify annotation for the task"), - ) + default_permissions = () def get_upload_dirname(self): return os.path.join(self.path, ".upload") @@ -91,11 +90,16 @@ class Segment(models.Model): start_frame = models.IntegerField() stop_frame = models.IntegerField() + class Meta: + default_permissions = () + class Job(models.Model): segment = models.ForeignKey(Segment, on_delete=models.CASCADE) - annotator = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + assignee = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) status = models.CharField(max_length=32, default=StatusChoice.ANNOTATION) - # TODO: add sub-issue number for the task + + class Meta: + default_permissions = () class Label(models.Model): task = models.ForeignKey(Task, on_delete=models.CASCADE) @@ -104,6 +108,10 @@ class Label(models.Model): def __str__(self): return self.name + class Meta: + default_permissions = () + + def parse_attribute(text): match = re.match(r'^([~@])(\w+)=(\w+):(.+)?$', text) prefix = match.group(1) @@ -120,6 +128,9 @@ class AttributeSpec(models.Model): label = models.ForeignKey(Label, on_delete=models.CASCADE) text = models.CharField(max_length=1024) + class Meta: + default_permissions = () + def get_attribute(self): return parse_attribute(self.text) @@ -143,17 +154,20 @@ def get_values(self): attr = self.get_attribute() return attr['values'] - def __str__(self): return self.get_attribute()['name'] + class AttributeVal(models.Model): # TODO: add a validator here to be sure that it corresponds to self.label id = models.BigAutoField(primary_key=True) spec = models.ForeignKey(AttributeSpec, on_delete=models.CASCADE) value = SafeCharField(max_length=64) + class Meta: abstract = True + default_permissions = () + class Annotation(models.Model): job = models.ForeignKey(Job, on_delete=models.CASCADE) @@ -161,14 +175,17 @@ class Annotation(models.Model): frame = models.PositiveIntegerField() group_id = models.PositiveIntegerField(default=0) client_id = models.BigIntegerField(default=-1) + class Meta: abstract = True class Shape(models.Model): occluded = models.BooleanField(default=False) z_order = models.IntegerField(default=0) + class Meta: abstract = True + default_permissions = () class BoundingBox(Shape): id = models.BigAutoField(primary_key=True) @@ -176,14 +193,18 @@ class BoundingBox(Shape): ytl = models.FloatField() xbr = models.FloatField() ybr = models.FloatField() + class Meta: abstract = True + default_permissions = () class PolyShape(Shape): id = models.BigAutoField(primary_key=True) points = models.TextField() + class Meta: abstract = True + default_permissions = () class LabeledBox(Annotation, BoundingBox): pass @@ -222,6 +243,7 @@ class TrackedObject(models.Model): outside = models.BooleanField(default=False) class Meta: abstract = True + default_permissions = () class TrackedBox(TrackedObject, BoundingBox): pass diff --git a/cvat/apps/engine/static/engine/js/annotationUI.js b/cvat/apps/engine/static/engine/js/annotationUI.js index 85035a7b51b..1cbd9079c0a 100644 --- a/cvat/apps/engine/static/engine/js/annotationUI.js +++ b/cvat/apps/engine/static/engine/js/annotationUI.js @@ -518,9 +518,8 @@ function setupMenu(job, shapeCollectionModel, annotationParser, aamModel, player $('#statTaskStatus').prop("value", job.status).on('change', (e) => { $.ajax({ type: 'POST', - url: 'save/job/status', + url: 'save/status/job/' + window.cvat.job.id, data: JSON.stringify({ - jid: window.cvat.job.id, status: e.target.value }), contentType: "application/json; charset=utf-8", diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 626ae47b4f7..7f6102f614f 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -249,13 +249,6 @@ def get_job(jid): return response -def is_task_owner(user, tid): - try: - return user == models.Task.objects.get(pk=tid).owner or \ - user.groups.filter(name='admin').exists() - except: - return False - @transaction.atomic def rq_handler(job, exc_type, exc_value, traceback): tid = job.id.split('/')[1] diff --git a/cvat/apps/engine/urls.py b/cvat/apps/engine/urls.py index b12e3017233..99aa48a9752 100644 --- a/cvat/apps/engine/urls.py +++ b/cvat/apps/engine/urls.py @@ -23,5 +23,5 @@ path('get/annotation/job/', views.get_annotation), path('get/username', views.get_username), path('save/exception/', views.catch_client_exception), - path('save/job/status', views.save_job_status), + path('save/status/job/', views.save_job_status), ] diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 0b76de50d98..f85a1861eb4 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -10,7 +10,7 @@ from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import redirect, render from django.conf import settings -from django.contrib.auth.decorators import permission_required +from rules.contrib.views import permission_required, objectgetter from django.views.decorators.gzip import gzip_page from sendfile import sendfile @@ -24,7 +24,8 @@ ############################# High Level server API @login_required -@permission_required('engine.view_task', raise_exception=True) +@permission_required(perm=['engine.job.access'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def catch_client_exception(request, jid): data = json.loads(request.body.decode('utf-8')) for event in data['exceptions']: @@ -44,7 +45,7 @@ def dispatch_request(request): return redirect('/dashboard/') @login_required -@permission_required('engine.add_task', raise_exception=True) +@permission_required(perm=['engine.task.create'], raise_exception=True) def create_task(request): """Create a new annotation task""" @@ -103,10 +104,10 @@ def create_task(request): return JsonResponse({'tid': db_task.id}) @login_required -@permission_required('engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def check_task(request, tid): """Check the status of a task""" - try: slogger.glob.info("check task #{}".format(tid)) response = task.check(tid) @@ -117,7 +118,8 @@ def check_task(request, tid): return JsonResponse(response) @login_required -@permission_required('engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def get_frame(request, tid, frame): """Stream corresponding from for the task""" @@ -131,14 +133,12 @@ def get_frame(request, tid, frame): return HttpResponseBadRequest(str(e)) @login_required -@permission_required('engine.delete_task', raise_exception=True) +@permission_required(perm=['engine.task.delete'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def delete_task(request, tid): """Delete the task""" try: slogger.glob.info("delete task #{}".format(tid)) - if not task.is_task_owner(request.user, tid): - return HttpResponseBadRequest("You don't have permissions to delete the task.") - task.delete(tid) except Exception as e: slogger.glob.error("cannot delete task #{}".format(tid), exc_info=True) @@ -147,14 +147,12 @@ def delete_task(request, tid): return HttpResponse() @login_required -@permission_required('engine.change_task', raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def update_task(request, tid): """Update labels for the task""" try: slogger.task[tid].info("update task request") - if not task.is_task_owner(request.user, tid): - return HttpResponseBadRequest("You don't have permissions to change the task.") - labels = request.POST['labels'] task.update(tid, labels) except Exception as e: @@ -164,7 +162,8 @@ def update_task(request, tid): return HttpResponse() @login_required -@permission_required(perm='engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def get_task(request, tid): try: slogger.task[tid].info("get task request") @@ -176,7 +175,8 @@ def get_task(request, tid): return JsonResponse(response, safe=False) @login_required -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.job.access'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def get_job(request, jid): try: slogger.job[jid].info("get job #{} request".format(jid)) @@ -188,7 +188,8 @@ def get_job(request, jid): return JsonResponse(response, safe=False) @login_required -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def dump_annotation(request, tid): try: slogger.task[tid].info("dump annotation request") @@ -201,7 +202,8 @@ def dump_annotation(request, tid): @login_required @gzip_page -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def check_annotation(request, tid): try: slogger.task[tid].info("check annotation") @@ -215,7 +217,8 @@ def check_annotation(request, tid): @login_required @gzip_page -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def download_annotation(request, tid): try: slogger.task[tid].info("get dumped annotation") @@ -231,7 +234,8 @@ def download_annotation(request, tid): @login_required @gzip_page -@permission_required(perm=['engine.view_task', 'engine.view_annotation'], raise_exception=True) +@permission_required(perm=['engine.job.access'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def get_annotation(request, jid): try: slogger.job[jid].info("get annotation for {} job".format(jid)) @@ -243,7 +247,8 @@ def get_annotation(request, jid): return JsonResponse(response, safe=False) @login_required -@permission_required(perm=['engine.view_task', 'engine.change_annotation'], raise_exception=True) +@permission_required(perm=['engine.job.change'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) def save_annotation_for_job(request, jid): try: slogger.job[jid].info("save annotation for {} job".format(jid)) @@ -263,7 +268,8 @@ def save_annotation_for_job(request, jid): return HttpResponse() @login_required -@permission_required(perm=['engine.view_task', 'engine.change_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(models.Task, 'tid'), raise_exception=True) def save_annotation_for_task(request, tid): try: slogger.task[tid].info("save annotation request") @@ -276,11 +282,11 @@ def save_annotation_for_task(request, tid): return HttpResponse() @login_required -@permission_required(perm=['engine.view_task', 'engine.change_task'], raise_exception=True) -def save_job_status(request): +@permission_required(perm=['engine.job.change'], + fn=objectgetter(models.Job, 'jid'), raise_exception=True) +def save_job_status(request, jid): try: data = json.loads(request.body.decode('utf-8')) - jid = data['jid'] status = data['status'] slogger.job[jid].info("changing job status request") task.save_job_status(jid, status, request.user.username) diff --git a/cvat/apps/tf_annotation/views.py b/cvat/apps/tf_annotation/views.py index 9986a3ecc5f..8ec7b7eb7d0 100644 --- a/cvat/apps/tf_annotation/views.py +++ b/cvat/apps/tf_annotation/views.py @@ -6,7 +6,7 @@ from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, QueryDict from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import render -from django.contrib.auth.decorators import permission_required +from rules.contrib.views import permission_required, objectgetter from cvat.apps.authentication.decorators import login_required from cvat.apps.engine.models import Task as TaskModel from cvat.apps.engine import annotation, task @@ -285,14 +285,12 @@ def get_meta_info(request): @login_required -@permission_required(perm=['engine.view_task', 'engine.change_annotation'], raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(TaskModel, 'tid'), raise_exception=True) def create(request, tid): slogger.glob.info('tf annotation create request for task {}'.format(tid)) try: db_task = TaskModel.objects.get(pk=tid) - if not task.is_task_owner(request.user, tid): - raise Exception('Not enought of permissions for tf annotation') - queue = django_rq.get_queue('low') job = queue.fetch_job('tf_annotation.create/{}'.format(tid)) if job is not None and (job.is_started or job.is_queued): @@ -346,7 +344,8 @@ def create(request, tid): return HttpResponse() @login_required -@permission_required(perm='engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.access'], + fn=objectgetter(TaskModel, 'tid'), raise_exception=True) def check(request, tid): try: queue = django_rq.get_queue('low') @@ -375,7 +374,8 @@ def check(request, tid): @login_required -@permission_required(perm='engine.view_task', raise_exception=True) +@permission_required(perm=['engine.task.change'], + fn=objectgetter(TaskModel, 'tid'), raise_exception=True) def cancel(request, tid): try: queue = django_rq.get_queue('low') diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index ad8cbb714a3..bf4e6c1e4a2 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -1,5 +1,5 @@ click==6.7 -Django==2.0.9 +Django==2.1.3 django-appconf==1.0.2 django-auth-ldap==1.4.0 django-cacheops==4.0.6 @@ -15,12 +15,13 @@ pytz==2018.3 pyunpack==0.1.2 rcssmin==1.0.6 redis==2.10.6 -requests==2.18.4 +requests==2.20.0 rjsmin==1.0.12 rq==0.10.0 scipy==1.0.1 sqlparse==0.2.4 django-sendfile==0.3.11 -dj-pagination==2.3.2 +dj-pagination==2.4.0 python-logstash==0.4.6 django-revproxy==0.9.15 +rules==2.0 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 6cef92eca63..015f2c003fd 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -54,7 +54,8 @@ 'cacheops', 'sendfile', 'dj_pagination', - 'revproxy' + 'revproxy', + 'rules' ] if 'yes' == os.environ.get('TF_ANNOTATION', 'no'): @@ -100,6 +101,18 @@ WSGI_APPLICATION = 'cvat.wsgi.application' +# Django Auth +DJANGO_AUTH_TYPE = 'BASIC' +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = '/' +AUTH_LOGIN_NOTE = '

Have not registered yet? Register here.

' + +AUTHENTICATION_BACKENDS = [ + 'rules.permissions.ObjectPermissionBackend', + 'django.contrib.auth.backends.ModelBackend' +] + + # Django-RQ # https://github.com/rq/django-rq