Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Analytics access #8509

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

- Access to /analytics can now be granted
(<https://github.com/cvat-ai/cvat/pull/8509>)
6 changes: 6 additions & 0 deletions cvat-core/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class User {
public readonly isSuperuser: boolean;
public readonly isActive: boolean;
public readonly isVerified: boolean;
public readonly hasAnalyticsAccess: boolean;

constructor(initialData: SerializedUser) {
const data = {
Expand All @@ -33,6 +34,7 @@ export default class User {
is_superuser: null,
is_active: null,
email_verification_required: null,
has_analytics_access: null,
};

for (const property in data) {
Expand Down Expand Up @@ -80,6 +82,9 @@ export default class User {
isVerified: {
get: () => !data.email_verification_required,
},
hasAnalyticsAccess: {
get: () => data.has_analytics_access,
},
}),
);
}
Expand All @@ -98,6 +103,7 @@ export default class User {
is_superuser: this.isSuperuser,
is_active: this.isActive,
email_verification_required: this.isVerified,
has_analytics_access: this.hasAnalyticsAccess,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SerializedUser also should be updated

};
}
}
2 changes: 1 addition & 1 deletion cvat-ui/src/components/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ function HeaderComponent(props: Props): JSX.Element {
Models
</Button>
) : null}
{isAnalyticsPluginActive && user.isSuperuser ? (
{isAnalyticsPluginActive && (user.isSuperuser || user.hasAnalyticsAccess) ? (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just rely on hasAnalyticsAccess here and always return has_analytics_access == True from the server for admins?

<Button
className={getButtonClassName('analytics', false)}
type='link'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.15 on 2024-10-04 10:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("engine", "0084_honeypot_support"),
]

operations = [
migrations.AddField(
model_name="profile",
name="has_analytics_access",
field=models.BooleanField(
default=False,
help_text="Designates whether the user can access /analytics.",
verbose_name="has /analytics access",
),
),
]
15 changes: 12 additions & 3 deletions cvat/apps/engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@

from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.storage import FileSystemStorage
from django.core.exceptions import ValidationError
from django.core.files.storage import FileSystemStorage
from django.db import IntegrityError, models, transaction
from django.db.models.fields import FloatField
from django.db.models import Q, TextChoices
from django.db.models.fields import FloatField
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field

from cvat.apps.engine.lazy_list import LazyList
from cvat.apps.engine.model_utils import MaybeUndefined
from cvat.apps.engine.utils import parse_specific_attributes, chunked_list
from cvat.apps.engine.utils import chunked_list, parse_specific_attributes
from cvat.apps.events.utils import cache_deleted


class SafeCharField(models.CharField):
def get_prep_value(self, value):
value = super().get_prep_value(value)
Expand Down Expand Up @@ -1085,9 +1087,16 @@ class TrackedShapeAttributeVal(AttributeVal):
shape = models.ForeignKey(TrackedShape, on_delete=models.DO_NOTHING,
related_name='attributes', related_query_name='attribute')


class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
rating = models.FloatField(default=0.0)
has_analytics_access = models.BooleanField(
_("has /analytics access"),
default=False,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not really think that it's the desired approach to have has_analytics_access==False for admins.

help_text=_("Designates whether the user can access /analytics."),
)


class Issue(TimestampedModel):
frame = models.PositiveIntegerField()
Expand Down
12 changes: 11 additions & 1 deletion cvat/apps/engine/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,28 +221,35 @@ class Meta:
model = User
fields = ('url', 'id', 'username', 'first_name', 'last_name')


class UserSerializer(serializers.ModelSerializer):
groups = serializers.SlugRelatedField(many=True,
slug_field='name', queryset=Group.objects.all())
has_analytics_access = serializers.BooleanField(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it supposed to be changed only through django admin panel? If this is the case, then this field should be marked as read_only. (If no, then you need to redefine the UserSerializer.update method to handle has_analytics_access changing)

source='profile.has_analytics_access',
required=False,
)

class Meta:
model = User
fields = ('url', 'id', 'username', 'first_name', 'last_name', 'email',
'groups', 'is_staff', 'is_superuser', 'is_active', 'last_login',
'date_joined')
'date_joined', 'has_analytics_access')
read_only_fields = ('last_login', 'date_joined')
write_only_fields = ('password', )
extra_kwargs = {
'last_login': { 'allow_null': True }
}


class DelimitedStringListField(serializers.ListField):
def to_representation(self, value):
return super().to_representation(value.split('\n'))

def to_internal_value(self, data):
return '\n'.join(super().to_internal_value(data))


class AttributeSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
values = DelimitedStringListField(allow_empty=True,
Expand All @@ -253,6 +260,7 @@ class Meta:
model = models.AttributeSpec
fields = ('id', 'name', 'mutable', 'input_type', 'default_value', 'values')


class SublabelSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
attributes = AttributeSerializer(many=True, source='attributespec_set', default=[],
Expand All @@ -271,6 +279,7 @@ class Meta:
fields = ('id', 'name', 'color', 'attributes', 'type', 'has_parent', )
read_only_fields = ('parent',)


class SkeletonSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
svg = serializers.CharField(allow_blank=True, required=False)
Expand All @@ -279,6 +288,7 @@ class Meta:
model = models.Skeleton
fields = ('id', 'svg',)


class LabelSerializer(SublabelSerializer):
deleted = serializers.BooleanField(required=False, write_only=True,
help_text='Delete the label. Only applicable in the PATCH methods of a project or a task.')
Expand Down
6 changes: 6 additions & 0 deletions cvat/apps/engine/tests/test_rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,8 @@ def _check_data(self, user, data, is_full):
extra_check("is_active", data)
extra_check("last_login", data)
extra_check("date_joined", data)
extra_check("has_analytics_access", data)


class UserListAPITestCase(UserAPITestCase):
def _run_api_v2_users(self, user):
Expand Down Expand Up @@ -671,6 +673,7 @@ def test_api_v2_users_no_auth(self):
response = self._run_api_v2_users(None)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)


class UserSelfAPITestCase(UserAPITestCase):
def _run_api_v2_users_self(self, user):
with ForceLogin(user, self.client):
Expand Down Expand Up @@ -698,6 +701,7 @@ def test_api_v2_users_self_no_auth(self):
response = self._run_api_v2_users_self(None)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)


class UserGetAPITestCase(UserAPITestCase):
def _run_api_v2_users_id(self, user, user_id):
with ForceLogin(user, self.client):
Expand Down Expand Up @@ -740,6 +744,7 @@ def test_api_v2_users_id_no_auth(self):
response = self._run_api_v2_users_id(None, self.user.id)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)


class UserPartialUpdateAPITestCase(UserAPITestCase):
def _run_api_v2_users_id(self, user, user_id, data):
with ForceLogin(user, self.client):
Expand Down Expand Up @@ -786,6 +791,7 @@ def test_api_v2_users_id_no_auth_partial(self):
response = self._run_api_v2_users_id(None, self.user.id, data)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)


class UserDeleteAPITestCase(UserAPITestCase):
def _run_api_v2_users_id(self, user, user_id):
with ForceLogin(user, self.client):
Expand Down
12 changes: 12 additions & 0 deletions cvat/apps/iam/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from django.utils.translation import gettext_lazy as _

from cvat.apps.engine.models import Profile


class ProfileInline(admin.StackedInline):
model = Profile

fieldsets = (
(None, {'fields': ('has_analytics_access', )}),
)


class CustomUserAdmin(UserAdmin):
inlines = (ProfileInline,)
list_display = ("username", "email", "first_name", "last_name", "is_active", "is_staff")
fieldsets = (
(None, {'fields': ('username', 'password')}),
Expand Down
21 changes: 17 additions & 4 deletions cvat/apps/iam/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from attrs import define, field
from django.apps import AppConfig
from django.conf import settings
from django.db.models import Q, Model
from django.db.models import Model, Q
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission

Expand All @@ -24,15 +24,18 @@

from .utils import add_opa_rules_path


class StrEnum(str, Enum):
def __str__(self) -> str:
return self.value


@define
class PermissionResult:
allow: bool
reasons: List[str] = field(factory=list)


def get_organization(request, obj):
# Try to get organization from an object otherwise, return the organization that is specified in query parameters
if isinstance(obj, Organization):
Expand All @@ -56,6 +59,7 @@ def get_organization(request, obj):

return request.iam_context['organization']


def get_membership(request, organization):
if organization is None:
return None
Expand All @@ -66,9 +70,11 @@ def get_membership(request, organization):
is_active=True
).first()


def build_iam_context(request, organization: Optional[Organization], membership: Optional[Membership]):
return {
'user_id': request.user.id,
'has_analytics_access': request.user.profile.has_analytics_access,
Eldies marked this conversation as resolved.
Show resolved Hide resolved
'group_name': request.iam_context['privilege'],
'org_id': getattr(organization, 'id', None),
'org_slug': getattr(organization, 'slug', None),
Expand All @@ -94,6 +100,7 @@ class OpenPolicyAgentPermission(metaclass=ABCMeta):
org_role: Optional[str]
scope: str
obj: Optional[Any]
has_analytics_access: bool

@classmethod
@abstractmethod
Expand Down Expand Up @@ -126,7 +133,8 @@ def __init__(self, **kwargs):
'auth': {
'user': {
'id': self.user_id,
'privilege': self.group_name
'privilege': self.group_name,
'has_analytics_access': self.has_analytics_access,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like these changes should be encapsulated in LogViewerPermission.

},
'organization': {
'id': self.org_id,
Expand Down Expand Up @@ -219,11 +227,14 @@ def get_per_field_update_scopes(cls, request, scopes_per_field):

return scopes


T = TypeVar('T', bound=Model)


def is_public_obj(obj: T) -> bool:
return getattr(obj, "is_public", False)


class PolicyEnforcer(BasePermission):
# pylint: disable=no-self-use
def check_permission(self, request, view, obj) -> bool:
Expand Down Expand Up @@ -258,13 +269,15 @@ def is_metadata_request(request, view):
return request.method == 'OPTIONS' \
or (request.method == 'POST' and view.action == 'metadata' and len(request.data) == 0)


class IsAuthenticatedOrReadPublicResource(BasePermission):
def has_object_permission(self, request, view, obj) -> bool:
return bool(
request.user and request.user.is_authenticated or
request.method == 'GET' and is_public_obj(obj)
(request.user and request.user.is_authenticated) or
(request.method == 'GET' and is_public_obj(obj))
)


def load_app_permissions(config: AppConfig) -> None:
"""
Ensures that permissions and OPA rules from the given app are loaded.
Expand Down
7 changes: 6 additions & 1 deletion cvat/apps/log_viewer/rules/analytics.rego
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import data.utils
# "auth": {
# "user": {
# "id": <num>,
# "privilege": <"admin"|"business"|"user"|"worker"> or null
# "privilege": <"admin"|"business"|"user"|"worker"> or null,
# "has_analytics_access": <true|false>
# },
# "organization": {
# "id": <num>,
Expand Down Expand Up @@ -37,3 +38,7 @@ allow if {
input.scope == utils.VIEW
utils.has_perm(utils.BUSINESS)
}

allow if {
input.auth.user.has_analytics_access
}
7 changes: 4 additions & 3 deletions cvat/apps/log_viewer/rules/tests/configs/analytics.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
view,Analytics,N/A,N/A,resource['visibility']=='public',GET,"/analytics",business,N/A
view,Analytics,N/A,N/A,,GET,"/analytics",admin,N/A
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership,HasAnalyticsAccess
view,Analytics,N/A,N/A,resource['visibility']=='public',GET,"/analytics",business,N/A,N/A
view,Analytics,N/A,N/A,,GET,"/analytics",admin,N/A,N/A
view,Analytics,N/A,N/A,,GET,"/analytics",none,N/A,true
Loading
Loading