Better reporting for user limits (#5225)

- Added explanatory messages for actions denied for user limits
- Fixed few rules and checks
- Upgraded OPA version
main
Maxim Zhiltsov 3 years ago committed by GitHub
parent aa4980eea5
commit ec3e1f34a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -246,7 +246,7 @@ jobs:
python cvat/apps/iam/rules/tests/generate_tests.py \
--output-dir cvat/apps/iam/rules/
curl -L -o opa https://openpolicyagent.org/downloads/v0.34.2/opa_linux_amd64_static
curl -L -o opa https://openpolicyagent.org/downloads/v0.45.0/opa_linux_amd64_static
chmod +x ./opa
./opa test cvat/apps/iam/rules

@ -83,6 +83,8 @@ jobs:
pip3 install --user -r tests/python/requirements.txt
- name: REST API and SDK tests
# We don't have external services in Helm tests, so we ignore corresponding cases
# They are still tested without Helm
run: |
kubectl cp tests/mounted_file_share/images $(kubectl get pods -l component=server -o jsonpath='{.items[0].metadata.name}'):/home/django/share
pytest --platform=kube -m "not with_external_services" tests/python

@ -213,7 +213,7 @@ jobs:
python cvat/apps/iam/rules/tests/generate_tests.py \
--output-dir cvat/apps/iam/rules/
curl -L -o opa https://openpolicyagent.org/downloads/v0.34.2/opa_linux_amd64_static
curl -L -o opa https://openpolicyagent.org/downloads/v0.45.0/opa_linux_amd64_static
chmod +x ./opa
./opa test cvat/apps/iam/rules

@ -221,7 +221,7 @@ jobs:
- name: OPA tests
run: |
curl -L -o opa https://openpolicyagent.org/downloads/v0.34.2/opa_linux_amd64_static
curl -L -o opa https://openpolicyagent.org/downloads/v0.45.0/opa_linux_amd64_static
chmod +x ./opa
./opa test cvat/apps/iam/rules

File diff suppressed because it is too large Load Diff

@ -23,9 +23,6 @@ import data.organizations
# "id": <num>,
# "owner": { "id": <num> },
# "organization": { "id": <num> } or null,
# "user": {
# "num_resources": <num>
# }
# }
# }

@ -0,0 +1,129 @@
package limits
import future.keywords.if
import future.keywords.in
import future.keywords.contains
import data.utils
CAP_USER_SANDBOX_TASKS = "USER_SANDBOX_TASKS"
CAP_USER_SANDBOX_PROJECTS = "USER_SANDBOX_PROJECTS"
CAP_TASKS_IN_USER_SANDBOX_PROJECT = "TASKS_IN_USER_SANDBOX_PROJECT"
CAP_USER_OWNED_ORGS = "USER_OWNED_ORGS"
CAP_USER_SANDBOX_CLOUD_STORAGES = "USER_SANDBOX_CLOUD_STORAGES"
CAP_ORG_TASKS = "ORG_TASKS"
CAP_ORG_PROJECTS = "ORG_PROJECTS"
CAP_TASKS_IN_ORG_PROJECT = "TASKS_IN_ORG_PROJECT"
CAP_ORG_CLOUD_STORAGES = "ORG_CLOUD_STORAGES"
CAP_ORG_COMMON_WEBHOOKS = "ORG_COMMON_WEBHOOKS"
CAP_PROJECT_WEBHOOKS = "PROJECT_WEBHOOKS"
check_limit_exceeded(current, max) {
null != max
current >= max
}
problems contains "user tasks limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_USER_SANDBOX_TASKS].used,
input.resource.limits[CAP_USER_SANDBOX_TASKS].max
)
}
problems contains "user projects limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_USER_SANDBOX_PROJECTS].used,
input.resource.limits[CAP_USER_SANDBOX_PROJECTS].max
)
}
problems contains "user project tasks limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_TASKS_IN_USER_SANDBOX_PROJECT].used,
input.resource.limits[CAP_TASKS_IN_USER_SANDBOX_PROJECT].max
)
}
problems contains "org tasks limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_ORG_TASKS].used,
input.resource.limits[CAP_ORG_TASKS].max
)
}
problems contains "org projects limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_ORG_PROJECTS].used,
input.resource.limits[CAP_ORG_PROJECTS].max
)
}
problems contains "org project tasks limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_TASKS_IN_ORG_PROJECT].used,
input.resource.limits[CAP_TASKS_IN_ORG_PROJECT].max
)
}
problems contains "project webhooks limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_PROJECT_WEBHOOKS].used,
input.resource.limits[CAP_PROJECT_WEBHOOKS].max
)
}
problems contains "org webhooks limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_ORG_COMMON_WEBHOOKS].used,
input.resource.limits[CAP_ORG_COMMON_WEBHOOKS].max
)
}
problems contains "user orgs limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_USER_OWNED_ORGS].used,
input.resource.limits[CAP_USER_OWNED_ORGS].max
)
}
problems contains "user cloud storages limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_USER_SANDBOX_CLOUD_STORAGES].used,
input.resource.limits[CAP_USER_SANDBOX_CLOUD_STORAGES].max
)
}
problems contains "org cloud storages limit reached" if {
check_limit_exceeded(
input.resource.limits[CAP_ORG_CLOUD_STORAGES].used,
input.resource.limits[CAP_ORG_CLOUD_STORAGES].max
)
}
# In the case of invalid input or no applicable limits,
# we deny the request. We suppose that we always check at least 1
# limit, and this package is queried by IAM only when there are
# limits to check in the input scope.
default result = {
"allow": false,
"reasons": []
}
result := {
"allow": true,
"reasons": [],
} if {
utils.is_admin
} else := {
"allow": count(problems) == 0,
"reasons": problems
} if {
not utils.is_admin
count(input.resource.limits) != 0
}
allow := result.allow

@ -15,10 +15,6 @@ import data.utils
# "owner": {
# "id": <num>
# },
# "user": {
# "num_resources": <num>,
# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null
# }
# }
# }
@ -68,7 +64,6 @@ allow {
allow {
input.scope == utils.CREATE
input.resource.user.num_resources == 0
utils.has_perm(utils.USER)
}

@ -26,9 +26,6 @@ import data.organizations
# "owner": { "id": <num> },
# "assignee": { "id": <num> },
# "organization": { "id": <num> } or null,
# "user": {
# "num_resources": <num>
# }
# }
# }
@ -49,14 +46,12 @@ allow {
allow {
{ utils.CREATE, utils.IMPORT_BACKUP }[input.scope]
utils.is_sandbox
input.resource.user.num_resources < 3
utils.has_perm(utils.USER)
}
allow {
{ utils.CREATE, utils.IMPORT_BACKUP }[input.scope]
input.auth.organization.id == input.resource.organization.id
input.resource.user.num_resources < 3
utils.has_perm(utils.USER)
organizations.has_perm(organizations.SUPERVISOR)
}

@ -1,4 +1,8 @@
package tasks
import future.keywords.if
import future.keywords.in
import data.utils
import data.organizations
@ -33,9 +37,6 @@ import data.organizations
# "assignee": { "id": <num> },
# "organization": { "id": <num> } or null,
# } or null,
# "user": {
# "num_resources": <num>
# }
# }
# }
@ -85,7 +86,6 @@ allow {
{ utils.CREATE, utils.IMPORT_BACKUP }[input.scope]
utils.is_sandbox
utils.has_perm(utils.USER)
input.resource.user.num_resources < 10
}
allow {
@ -93,7 +93,6 @@ allow {
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.USER)
organizations.has_perm(organizations.SUPERVISOR)
input.resource.user.num_resources < 10
}
allow {
@ -113,7 +112,6 @@ allow {
input.scope == utils.CREATE_IN_PROJECT
utils.is_sandbox
utils.has_perm(utils.USER)
input.resource.user.num_resources < 10
is_project_staff
}
@ -122,7 +120,6 @@ allow {
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.USER)
organizations.has_perm(organizations.SUPERVISOR)
input.resource.user.num_resources < 10
}
allow {
@ -131,7 +128,6 @@ allow {
utils.has_perm(utils.USER)
organizations.has_perm(organizations.WORKER)
is_project_staff
input.resource.user.num_resources < 10
}
allow {

@ -0,0 +1,14 @@
TestKind,Capability,CapKind
single,USER_SANDBOX_TASKS,max
single,USER_SANDBOX_PROJECTS,max
single,TASKS_IN_USER_SANDBOX_PROJECT,max
single,USER_OWNED_ORGS,max
single,USER_SANDBOX_CLOUD_STORAGES,max
single,ORG_TASKS,max
single,ORG_PROJECTS,max
single,TASKS_IN_ORG_PROJECT,max
single,ORG_CLOUD_STORAGES,max
single,ORG_COMMON_WEBHOOKS,max
single,PROJECT_WEBHOOKS,max
multi,"USER_SANDBOX_TASKS,USER_SANDBOX_PROJECTS",N/A
multi,,N/A
1 TestKind Capability CapKind
2 single USER_SANDBOX_TASKS max
3 single USER_SANDBOX_PROJECTS max
4 single TASKS_IN_USER_SANDBOX_PROJECT max
5 single USER_OWNED_ORGS max
6 single USER_SANDBOX_CLOUD_STORAGES max
7 single ORG_TASKS max
8 single ORG_PROJECTS max
9 single TASKS_IN_ORG_PROJECT max
10 single ORG_CLOUD_STORAGES max
11 single ORG_COMMON_WEBHOOKS max
12 single PROJECT_WEBHOOKS max
13 multi USER_SANDBOX_TASKS,USER_SANDBOX_PROJECTS N/A
14 multi N/A

@ -1,6 +1,5 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
create,Organization,N/A,N/A,"resource[""user""][""num_resources""] < 1",POST,/organizations,User,N/A
create,Organization,N/A,N/A,,POST,/organizations,Business,N/A
create,Organization,N/A,N/A,,POST,/organizations,User,N/A
list,N/A,N/A,N/A,,GET,/organizations,None,N/A
view,Organization,N/A,"Worker, Supervisor, Maintainer, Owner",,GET,/organizations/{id},None,N/A
view,Organization,N/A,None,,GET,/organizations/{id},Admin,N/A

1 Scope Resource Context Ownership Limit Method URL Privilege Membership
2 create Organization N/A N/A resource["user"]["num_resources"] < 1 POST /organizations User N/A
create Organization N/A N/A POST /organizations Business N/A
3 list N/A N/A N/A GET /organizations None N/A
4 view Organization N/A Worker, Supervisor, Maintainer, Owner GET /organizations/{id} None N/A
5 view Organization N/A None GET /organizations/{id} Admin N/A

@ -1,12 +1,8 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
create,Project,Sandbox,N/A,resource['user']['num_resources'] < 3,POST,/projects,User,N/A
create,Project,Organization,N/A,resource['user']['num_resources'] < 3,POST,/projects,User,Supervisor
create,Project,Sandbox,N/A,,POST,/projects,Business,N/A
create,Project,Organization,N/A,,POST,/projects,Business,Supervisor
import:backup,Project,Sandbox,N/A,resource['user']['num_resources'] < 3,POST,/projects/backup,User,N/A
import:backup,Project,Organization,N/A,resource['user']['num_resources'] < 3,POST,/projects/backup,User,Supervisor
import:backup,Project,Sandbox,N/A,,POST,/projects/backup,Business,N/A
import:backup,Project,Organization,N/A,,POST,/projects/backup,Business,Supervisor
create,Project,Sandbox,N/A,,POST,/projects,User,N/A
create,Project,Organization,N/A,,POST,/projects,User,Supervisor
import:backup,Project,Sandbox,N/A,,POST,/projects/backup,User,N/A
import:backup,Project,Organization,N/A,,POST,/projects/backup,User,Supervisor
list,N/A,Sandbox,N/A,,GET,/projects,None,N/A
list,N/A,Organization,N/A,,GET,/projects,None,Worker
view,Project,Sandbox,None,,GET,"/projects/{id}, /projects/{id}/tasks",Admin,N/A

1 Scope Resource Context Ownership Limit Method URL Privilege Membership
2 create Project Sandbox N/A resource['user']['num_resources'] < 3 POST /projects User N/A
3 create Project Organization N/A resource['user']['num_resources'] < 3 POST /projects User Supervisor
4 create import:backup Project Sandbox N/A POST /projects /projects/backup Business User N/A
5 create import:backup Project Organization N/A POST /projects /projects/backup Business User Supervisor
import:backup Project Sandbox N/A resource['user']['num_resources'] < 3 POST /projects/backup User N/A
import:backup Project Organization N/A resource['user']['num_resources'] < 3 POST /projects/backup User Supervisor
import:backup Project Sandbox N/A POST /projects/backup Business N/A
import:backup Project Organization N/A POST /projects/backup Business Supervisor
6 list N/A Sandbox N/A GET /projects None N/A
7 list N/A Organization N/A GET /projects None Worker
8 view Project Sandbox None GET /projects/{id}, /projects/{id}/tasks Admin N/A

@ -1,19 +1,12 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
create,Task,Sandbox,None,resource['user']['num_resources'] < 10,POST,/tasks,User,N/A
create,Task,Organization,None,resource['user']['num_resources'] < 10,POST,/tasks,User,Supervisor
create,Task,Sandbox,None,,POST,/tasks,Business,N/A
create,Task,Organization,None,,POST,/tasks,Business,Supervisor
import:backup,Task,Sandbox,None,resource['user']['num_resources'] < 10,POST,/tasks/backup,User,N/A
import:backup,Task,Organization,None,resource['user']['num_resources'] < 10,POST,/tasks/backup,User,Supervisor
import:backup,Task,Sandbox,None,,POST,/tasks/backup,Business,N/A
import:backup,Task,Organization,None,,POST,/tasks/backup,Business,Supervisor
create,Task,Sandbox,None,,POST,/tasks,User,N/A
create,Task,Organization,None,,POST,/tasks,User,Supervisor
import:backup,Task,Sandbox,None,,POST,/tasks/backup,User,N/A
import:backup,Task,Organization,None,,POST,/tasks/backup,User,Supervisor
create@project,"Task, Project",Sandbox,None,,POST,/tasks,Admin,N/A
create@project,"Task, Project",Sandbox,"Project:owner, Project:assignee",resource['user']['num_resources'] < 10,POST,/tasks,User,N/A
create@project,"Task, Project",Organization,None,resource['user']['num_resources'] < 10,POST,/tasks,User,Supervisor
create@project,"Task, Project",Organization,"Project:owner, Project:assignee",resource['user']['num_resources'] < 10,POST,/tasks,User,Worker
create@project,"Task, Project",Sandbox,"Project:owner, Project:assignee",,POST,/tasks,Business,N/A
create@project,"Task, Project",Organization,None,,POST,/tasks,Business,Supervisor
create@project,"Task, Project",Organization,"Project:owner, Project:assignee",,POST,/tasks,Business,Worker
create@project,"Task, Project",Sandbox,"Project:owner, Project:assignee",,POST,/tasks,User,N/A
create@project,"Task, Project",Organization,None,,POST,/tasks,User,Supervisor
create@project,"Task, Project",Organization,"Project:owner, Project:assignee",,POST,/tasks,User,Worker
view,Task,Sandbox,None,,GET,"/tasks/{id}, /tasks/{id}/status",Admin,N/A
view,Task,Sandbox,"Owner, Project:owner, Assignee, Project:assignee",,GET,"/tasks/{id}, /tasks/{id}/status",None,N/A
view,Task,Organization,None,,GET,"/tasks/{id}, /tasks/{id}/status",User,Maintainer

1 Scope Resource Context Ownership Limit Method URL Privilege Membership
2 create Task Sandbox None resource['user']['num_resources'] < 10 POST /tasks User N/A
3 create Task Organization None resource['user']['num_resources'] < 10 POST /tasks User Supervisor
4 create import:backup Task Sandbox None POST /tasks /tasks/backup Business User N/A
5 create import:backup Task Organization None POST /tasks /tasks/backup Business User Supervisor
import:backup Task Sandbox None resource['user']['num_resources'] < 10 POST /tasks/backup User N/A
import:backup Task Organization None resource['user']['num_resources'] < 10 POST /tasks/backup User Supervisor
import:backup Task Sandbox None POST /tasks/backup Business N/A
import:backup Task Organization None POST /tasks/backup Business Supervisor
6 create@project Task, Project Sandbox None POST /tasks Admin N/A
7 create@project Task, Project Sandbox Project:owner, Project:assignee resource['user']['num_resources'] < 10 POST /tasks User N/A
8 create@project Task, Project Organization None resource['user']['num_resources'] < 10 POST /tasks User Supervisor
9 create@project Task, Project Organization Project:owner, Project:assignee resource['user']['num_resources'] < 10 POST /tasks User Worker
create@project Task, Project Sandbox Project:owner, Project:assignee POST /tasks Business N/A
create@project Task, Project Organization None POST /tasks Business Supervisor
create@project Task, Project Organization Project:owner, Project:assignee POST /tasks Business Worker
10 view Task Sandbox None GET /tasks/{id}, /tasks/{id}/status Admin N/A
11 view Task Sandbox Owner, Project:owner, Assignee, Project:assignee GET /tasks/{id}, /tasks/{id}/status None N/A
12 view Task Organization None GET /tasks/{id}, /tasks/{id}/status User Maintainer

@ -1,10 +1,10 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
create@project,Webhook,Sandbox,N/A,,POST,/webhooks,Admin,N/A
create@project,Webhook,Sandbox,Project:owner,resource['num_resources'] < 10,POST,/webhooks,Worker,N/A
create@project,Webhook,Organization,N/A,resource['num_resources'] < 10,POST,/webhooks,Worker,Maintainer
create@project,Webhook,Organization,Project:owner,resource['num_resources'] < 10,POST,/webhooks,Worker,Worker
create@project,Webhook,Sandbox,Project:owner,,POST,/webhooks,Worker,N/A
create@project,Webhook,Organization,N/A,,POST,/webhooks,Worker,Maintainer
create@project,Webhook,Organization,Project:owner,,POST,/webhooks,Worker,Worker
create@organization,Webhook,Organization,N/A,,POST,/webhooks,Admin,N/A
create@organization,Webhook,Organization,N/A,resource['num_resources'] < 10,POST,/webhooks,Worker,Maintainer
create@organization,Webhook,Organization,N/A,,POST,/webhooks,Worker,Maintainer
update,Webhook,Sandbox,N/A,,PATCH,/webhooks/{id},Admin,N/A
update,Webhook,Sandbox,"Project:owner, owner",,PATCH,/webhooks/{id},Worker,N/A
update,Webhook,Organization,N/A,,PATCH,/webhooks/{id},Worker,Maintainer

1 Scope Resource Context Ownership Limit Method URL Privilege Membership
2 create@project Webhook Sandbox N/A POST /webhooks Admin N/A
3 create@project Webhook Sandbox Project:owner resource['num_resources'] < 10 POST /webhooks Worker N/A
4 create@project Webhook Organization N/A resource['num_resources'] < 10 POST /webhooks Worker Maintainer
5 create@project Webhook Organization Project:owner resource['num_resources'] < 10 POST /webhooks Worker Worker
6 create@organization Webhook Organization N/A POST /webhooks Admin N/A
7 create@organization Webhook Organization N/A resource['num_resources'] < 10 POST /webhooks Worker Maintainer
8 update Webhook Sandbox N/A PATCH /webhooks/{id} Admin N/A
9 update Webhook Sandbox Project:owner, owner PATCH /webhooks/{id} Worker N/A
10 update Webhook Organization N/A PATCH /webhooks/{id} Worker Maintainer

@ -0,0 +1,211 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import csv
import json
import os
import sys
import textwrap
from enum import Enum
from itertools import product
NAME = "limits"
class TestKinds(str, Enum):
single = "single"
multi = "multi"
def __str__(self) -> str:
return self.value.lower()
class CapKinds(str, Enum):
max = "max"
def __str__(self) -> str:
return self.value.lower()
def read_test_table(name):
# The table describes positive cases and test configurations
table = []
with open(os.path.join(sys.argv[1], f"{name}.csv")) as f:
for row in csv.DictReader(f):
table.append(row)
return table
test_table = read_test_table(NAME)
CAPABILITIES = {
entry["Capability"]: entry["CapKind"]
for entry in test_table
if entry["TestKind"] == TestKinds.single
}
ROLES = ["user", "admin"]
MAX_CAPABILITY_LIMIT_VALUES = [None, 5]
MAX_CAPABILITY_USED_VALUES = [2, 7]
def eval_rule(test_kind, role, capabilities, *, data):
if role == "admin":
return {
"allow": True,
"messages": 0,
}
allow = True
messages = 0
for capability in capabilities:
cap_name = capability["name"]
cap_kind = CAPABILITIES[cap_name]
cap_data = data["resource"]["limits"][cap_name]
if cap_kind == CapKinds.max:
cap_allow = (cap_data["max"] is None) or (cap_data["used"] < cap_data["max"])
messages += not cap_allow
allow &= cap_allow
else:
raise ValueError(f"Unknown capability kind {cap_kind}")
if not capabilities:
allow = False
messages = 0
return {
"allow": allow,
"messages": messages,
}
def _get_name(prefix, **kwargs):
name = prefix
for k, v in kwargs.items():
prefix = "_" + str(k)
if isinstance(v, dict):
if "id" in v:
v = v.copy()
v.pop("id")
if v:
name += _get_name(prefix, **v)
else:
name += "".join(
map(
lambda c: c if c.isalnum() else {"@": "_IN_"}.get(c, "_"),
f"{prefix}_{str(v).upper()}",
)
)
return name
def get_name(*args, **kwargs):
return _get_name("test", *args, **kwargs)
def generate_capability_cases(capability: str):
capability_kind = CAPABILITIES[capability]
if capability_kind == CapKinds.max:
for used, maximum in product(MAX_CAPABILITY_USED_VALUES, MAX_CAPABILITY_LIMIT_VALUES):
yield {"name": capability, "used": used, "max": maximum}
else:
raise ValueError(f"Unknown capability kind {capability_kind}")
def generate_test_data(test_kind, role, capabilities):
data = {
"auth": {"user": {"privilege": role}},
"resource": {
"limits": {},
},
}
for cap_case in capabilities:
cap_name = cap_case["name"]
cap_kind = CAPABILITIES[cap_case["name"]]
if cap_kind == CapKinds.max:
data["resource"]["limits"][cap_name] = {
"used": cap_case["used"],
"max": cap_case["max"],
}
else:
raise ValueError(f"Unknown capability type {cap_kind}")
return data
def generate_test_cases():
for config in test_table:
test_kind = config["TestKind"]
if test_kind == TestKinds.single:
capability = config["Capability"]
for role, cap_case in product(ROLES, generate_capability_cases(capability)):
yield dict(test_kind=test_kind, role=role, capabilities=[cap_case])
elif test_kind == TestKinds.multi:
if config["Capability"]:
capabilities = config["Capability"].split(",")
else:
capabilities = []
capability_cases = [
generate_capability_cases(capability) for capability in capabilities
]
for params in product(ROLES, *capability_cases):
role = params[0]
cap_case = params[1:]
yield dict(test_kind=test_kind, role=role, capabilities=cap_case)
else:
raise ValueError(f"Unknown test kind {test_kind}")
def gen_test_rego(name):
with open(f"{name}_test.gen.rego", "wt") as f:
f.write(f"package {name}\n\n")
for test_params in generate_test_cases():
test_data = generate_test_data(**test_params)
test_result = eval_rule(**test_params, data=test_data)
test_name = get_name(**test_params)
f.write(
textwrap.dedent(
"""
{test_name} {{
r := result with input as {data}
r.allow == {allow}
count(r.reasons) == {messages}
}}
"""
).format(
test_name=test_name,
allow=str(test_result["allow"]).lower(),
messages=test_result["messages"],
data=json.dumps(test_data),
)
)
# Write the script which is used to generate the file
with open(sys.argv[0]) as this_file:
f.write(f"\n\n# {os.path.split(sys.argv[0])[1]}\n")
for line in this_file:
if line.strip():
f.write(f"# {line}")
else:
f.write(f"#\n")
# Write rules which are used to generate the file
with open(os.path.join(sys.argv[1], f"{name}.csv")) as rego_file:
f.write(f"\n\n# {name}.csv\n")
for line in rego_file:
if line.strip():
f.write(f"# {line}")
else:
f.write(f"#\n")
gen_test_rego(NAME)

@ -27,7 +27,6 @@ import data.organizations
# "project": {
# "owner": { "id": num },
# } or null,
# "num_resources": <num>
# }
# }
#
@ -51,10 +50,8 @@ allow {
utils.is_sandbox
utils.has_perm(utils.USER)
is_project_owner
input.resource.num_resources < 10
}
allow {
input.scope == utils.LIST
utils.is_sandbox
@ -152,7 +149,6 @@ allow {
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.MAINTAINER)
input.resource.num_resources < 10
}
allow {
@ -168,6 +164,5 @@ allow {
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.WORKER)
input.resource.num_resources < 10
is_project_owner
}

@ -1,17 +1,26 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from contextlib import contextmanager
from itertools import repeat
import itertools
from typing import Sequence, Type
from unittest import mock
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from rest_framework.authtoken.models import Token
from django.test import override_settings
from cvat.apps.iam.urls import urlpatterns as iam_url_patterns
from cvat.apps.iam.views import ConfirmEmailViewEx
from django.urls import path, re_path
from allauth.account.views import EmailVerificationSentView
from cvat.apps.iam.permissions import OpenPolicyAgentPermission, PermissionResult
from cvat.apps.iam.urls import urlpatterns as iam_url_patterns
from cvat.apps.iam.views import ConfirmEmailViewEx
from cvat.apps.engine.models import User
urlpatterns = iam_url_patterns + [
re_path(r'^account-confirm-email/(?P<key>[-:\w]+)/$', ConfirmEmailViewEx.as_view(),
@ -20,6 +29,21 @@ urlpatterns = iam_url_patterns + [
name='account_email_verification_sent'),
]
class ForceLogin:
def __init__(self, user, client):
self.user = user
self.client = client
def __enter__(self):
if self.user:
self.client.force_login(self.user, backend='django.contrib.auth.backends.ModelBackend')
return self
def __exit__(self, exception_type, exception_value, traceback):
if self.user:
self.client.logout()
class UserRegisterAPITestCase(APITestCase):
user_data = {'first_name': 'test_first', 'last_name': 'test_last', 'username': 'test_username',
@ -73,3 +97,86 @@ class UserRegisterAPITestCase(APITestCase):
self._check_response(response, {'first_name': 'test_first', 'last_name': 'test_last',
'username': 'test_username', 'email': 'test_email@test.com',
'email_verification_required': True, 'key': None})
class TestIamApi(APITestCase):
@classmethod
def _make_permission_class(cls, results) -> Type[OpenPolicyAgentPermission]:
class _TestPerm(OpenPolicyAgentPermission):
def get_resource(self):
return {}
@classmethod
def create(cls, request, view, obj) -> Sequence[OpenPolicyAgentPermission]:
return [
cls.create_base_perm(request, view, None, obj, result=result)
for result in results
]
def check_access(self) -> PermissionResult:
return PermissionResult(allow=self.result[0], reasons=self.result[1])
return _TestPerm
@classmethod
@contextmanager
def _mock_permissions(cls, *perm_results):
with mock.patch('cvat.apps.iam.permissions.OpenPolicyAgentPermission.__subclasses__',
lambda: [cls._make_permission_class(perm_results)]
):
yield
ENDPOINT_WITH_AUTH = '/api/users/self'
def setUp(self):
self.client = APIClient()
import sys
sys.modules.pop('cvat.apps.iam.permissions', None)
@classmethod
def _create_db_users(cls):
cls.user = User.objects.create_user(username="user", password="user")
@classmethod
def setUpTestData(cls) -> None:
super().setUpTestData()
cls._create_db_users()
def test_can_report_denial_reason(self):
expected_reasons = ["hello", "world"]
with self._mock_permissions((False, expected_reasons)), \
ForceLogin(user=self.user, client=self.client):
response = self.client.get(self.ENDPOINT_WITH_AUTH)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.json(), expected_reasons)
def test_can_report_merged_denial_reasons(self):
expected_reasons = [["hello", "world"], ["hi", "there"]]
with self._mock_permissions(*zip(repeat(False), expected_reasons)), \
ForceLogin(user=self.user, client=self.client):
response = self.client.get(self.ENDPOINT_WITH_AUTH)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.json(), list(itertools.chain.from_iterable(expected_reasons)))
def test_can_allow_if_no_permission_matches(self):
with self._mock_permissions(), ForceLogin(user=self.user, client=self.client):
response = self.client.get(self.ENDPOINT_WITH_AUTH)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_can_allow_if_permissions_allow(self):
with self._mock_permissions((True, [])), \
ForceLogin(user=self.user, client=self.client):
response = self.client.get(self.ENDPOINT_WITH_AUTH)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_can_deny_if_some_permissions_deny(self):
expected_reasons = ["hello"]
with self._mock_permissions((True, []), (False, expected_reasons)), \
ForceLogin(user=self.user, client=self.client):
response = self.client.get(self.ENDPOINT_WITH_AUTH)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.json(), expected_reasons)

@ -0,0 +1,6 @@
from django.apps import AppConfig
class LimitManagerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'limit_manager'

@ -0,0 +1,230 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from enum import Enum, auto
from typing import Optional, cast
from attrs import define
from django.conf import settings
from cvat.apps.engine.models import CloudStorage, Project, Task
from cvat.apps.organizations.models import Organization
from cvat.apps.webhooks.models import Webhook
class Limits(Enum):
"""
Represents a capability which has an upper limit, and can be consumed.
Each capability is also supposed to have a separate CapabilityContext class,
representing its parameters. Different parameter combinations should each have
a different enum member, no member reuse is supposed for different limits.
"""
# TODO: for a capability with N params, there are O(k^N)
# possible limitation combinations. Not all are meaningful, but even though
# it is quite a large number. Example:
# A "task create" capability [user_id, org_id, project_id]
# yields the following possible limitations:
# - tasks from the user
# - tasks from the user outside orgs
# - tasks from the user inside orgs
# - tasks from the user in the org
# - tasks from the user in the project
# - tasks in the org
# - tasks in the org projects
# ...
#
# Currently, we will cover all of this with a single request to the limit manager.
# For each meaningful combination class a capability enum entry is supposed.
USER_SANDBOX_TASKS = auto()
USER_SANDBOX_PROJECTS = auto()
TASKS_IN_USER_SANDBOX_PROJECT = auto()
USER_OWNED_ORGS = auto()
USER_SANDBOX_CLOUD_STORAGES = auto()
ORG_TASKS = auto()
ORG_PROJECTS = auto()
TASKS_IN_ORG_PROJECT = auto()
ORG_CLOUD_STORAGES = auto()
ORG_COMMON_WEBHOOKS = auto()
PROJECT_WEBHOOKS = auto()
class CapabilityContext:
pass
@define(kw_only=True)
class UserCapabilityContext(CapabilityContext):
user_id: int
@define(kw_only=True)
class OrgCapabilityContext(CapabilityContext):
org_id: int
@define(kw_only=True)
class UserSandboxTasksContext(UserCapabilityContext):
pass
@define(kw_only=True)
class OrgTasksContext(OrgCapabilityContext):
pass
@define(kw_only=True)
class TasksInUserSandboxProjectContext(UserCapabilityContext):
project_id: int
@define(kw_only=True)
class TasksInOrgProjectContext(OrgCapabilityContext):
project_id: int
@define(kw_only=True)
class UserSandboxProjectsContext(UserCapabilityContext):
pass
@define(kw_only=True)
class OrgProjectsContext(OrgCapabilityContext):
pass
@define(kw_only=True)
class UserSandboxCloudStoragesContext(UserCapabilityContext):
pass
@define(kw_only=True)
class OrgCloudStoragesContext(OrgCapabilityContext):
pass
@define(kw_only=True)
class UserOrgsContext(UserCapabilityContext):
pass
@define(kw_only=True)
class ProjectWebhooksContext(CapabilityContext):
project_id: int
@define(kw_only=True)
class OrgCommonWebhooksContext(OrgCapabilityContext):
pass
@define(frozen=True)
class LimitStatus:
used: Optional[int]
max: Optional[int]
class LimitManager:
def get_status(self,
limit: Limits, *,
context: Optional[CapabilityContext] = None,
) -> LimitStatus:
if limit == Limits.USER_OWNED_ORGS:
assert context is not None
context = cast(UserOrgsContext, context)
return LimitStatus(
Organization.objects.filter(owner_id=context.user_id).count(),
settings.DEFAULT_LIMITS["USER_OWNED_ORGS"],
)
elif limit == Limits.USER_SANDBOX_PROJECTS:
assert context is not None
context = cast(UserSandboxProjectsContext, context)
return LimitStatus(
# TODO: check about active/removed projects
Project.objects.filter(owner=context.user_id, organization=None).count(),
settings.DEFAULT_LIMITS["USER_SANDBOX_PROJECTS"],
)
elif limit == Limits.ORG_PROJECTS:
assert context is not None
context = cast(OrgProjectsContext, context)
return LimitStatus(
# TODO: check about active/removed projects
Project.objects.filter(organization=context.org_id).count(),
settings.DEFAULT_LIMITS["ORG_PROJECTS"],
)
elif limit == Limits.USER_SANDBOX_TASKS:
assert context is not None
context = cast(UserSandboxTasksContext, context)
return LimitStatus(
# TODO: check about active/removed tasks
Task.objects.filter(owner=context.user_id, organization=None).count(),
settings.DEFAULT_LIMITS["USER_SANDBOX_TASKS"],
)
elif limit == Limits.ORG_TASKS:
assert context is not None
context = cast(OrgTasksContext, context)
return LimitStatus(
# TODO: check about active/removed tasks
Task.objects.filter(organization=context.org_id).count(),
settings.DEFAULT_LIMITS["ORG_TASKS"],
)
elif limit == Limits.TASKS_IN_USER_SANDBOX_PROJECT:
assert context is not None
context = cast(TasksInUserSandboxProjectContext, context)
return LimitStatus(
# TODO: check about active/removed tasks
Task.objects.filter(project=context.project_id).count(),
settings.DEFAULT_LIMITS["TASKS_IN_USER_SANDBOX_PROJECT"]
)
elif limit == Limits.TASKS_IN_ORG_PROJECT:
assert context is not None
context = cast(TasksInOrgProjectContext, context)
return LimitStatus(
# TODO: check about active/removed tasks
Task.objects.filter(project=context.project_id).count(),
settings.DEFAULT_LIMITS["TASKS_IN_ORG_PROJECT"]
)
elif limit == Limits.PROJECT_WEBHOOKS:
assert context is not None
context = cast(ProjectWebhooksContext, context)
return LimitStatus(
# We only limit webhooks per project, not per user
# TODO: think over this limit, maybe we should limit per user
Webhook.objects.filter(project=context.project_id).count(),
settings.DEFAULT_LIMITS["PROJECT_WEBHOOKS"]
)
elif limit == Limits.ORG_COMMON_WEBHOOKS:
assert context is not None
context = cast(OrgCommonWebhooksContext, context)
return LimitStatus(
Webhook.objects.filter(organization=context.org_id, project=None).count(),
settings.DEFAULT_LIMITS["ORG_COMMON_WEBHOOKS"]
)
elif limit == Limits.USER_SANDBOX_CLOUD_STORAGES:
assert context is not None
context = cast(UserSandboxCloudStoragesContext, context)
return LimitStatus(
CloudStorage.objects.filter(owner=context.user_id, organization=None).count(),
settings.DEFAULT_LIMITS["USER_SANDBOX_CLOUD_STORAGES"]
)
elif limit == Limits.ORG_CLOUD_STORAGES:
assert context is not None
context = cast(OrgCloudStoragesContext, context)
return LimitStatus(
CloudStorage.objects.filter(organization=context.org_id).count(),
settings.DEFAULT_LIMITS["ORG_CLOUD_STORAGES"]
)
raise NotImplementedError(f"Unknown capability {limit.name}")

@ -0,0 +1,11 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from django.db import models
import cvat.apps.limit_manager.core.limits as core
class Limits(core.Limits, models.TextChoices):
pass

@ -257,6 +257,22 @@ IAM_OPA_DATA_URL = f'{IAM_OPA_HOST}/v1/data'
LOGIN_URL = 'rest_login'
LOGIN_REDIRECT_URL = '/'
DEFAULT_LIMITS = {
"USER_SANDBOX_TASKS": 10,
"USER_SANDBOX_PROJECTS": 3,
"TASKS_IN_USER_SANDBOX_PROJECT": 5,
"USER_OWNED_ORGS": 1,
"USER_SANDBOX_CLOUD_STORAGES": 10,
"ORG_TASKS": 10,
"ORG_PROJECTS": 3,
"TASKS_IN_ORG_PROJECT": 5,
"ORG_CLOUD_STORAGES": 10,
"ORG_COMMON_WEBHOOKS": 20,
"PROJECT_WEBHOOKS": 10,
}
# ORG settings
ORG_INVITATION_CONFIRM = 'No'

@ -64,6 +64,24 @@ PASSWORD_HASHERS = (
'django.contrib.auth.hashers.MD5PasswordHasher',
)
# Most of the unit tests don't suppose user limitations
# Can be changed where the limits are supposed.
DEFAULT_LIMITS = {
"USER_SANDBOX_TASKS": None,
"USER_SANDBOX_PROJECTS": None,
"TASKS_IN_USER_SANDBOX_PROJECT": None,
"USER_OWNED_ORGS": None,
"USER_SANDBOX_CLOUD_STORAGES": None,
"ORG_TASKS": None,
"ORG_PROJECTS": None,
"TASKS_IN_ORG_PROJECT": None,
"ORG_CLOUD_STORAGES": None,
"ORG_COMMON_WEBHOOKS": None,
"PROJECT_WEBHOOKS": None,
}
# When you run ./manage.py test, Django looks at the TEST_RUNNER setting to
# determine what to do. By default, TEST_RUNNER points to
# 'django.test.runner.DiscoverRunner'. This class defines the default Django

@ -187,7 +187,7 @@ services:
cvat_opa:
container_name: cvat_opa
image: openpolicyagent/opa:0.34.2-rootless
image: openpolicyagent/opa:0.45.0-rootless
restart: always
networks:
cvat:

@ -137,8 +137,8 @@ cvat:
opa:
replicas: 1
image: openpolicyagent/opa
tag: 0.34.2-rootless
imagePullPolicy: IfNotPresent
tag: 0.45.0-rootless
imagepullploicy: IfNotPresent
labels: {}
# test: test
annotations: {}

@ -170,7 +170,7 @@ description: 'Installing a development environment for different operating syste
- Pull and run OpenPolicyAgent Docker image:
```bash
docker run -d --rm --name cvat_opa_debug -p 8181:8181 openpolicyagent/opa:0.34.2-rootless \
docker run -d --rm --name cvat_opa_debug -p 8181:8181 openpolicyagent/opa:0.45.0-rootless \
run --server --set=decision_logs.console=true --set=services.cvat.url=http://host.docker.internal:7000 \
--set=bundles.cvat.service=cvat --set=bundles.cvat.resource=/api/auth/rules
```

@ -162,13 +162,13 @@ python cvat/apps/iam/rules/tests/generate_tests.py \
- In a Docker container
```bash
docker run --rm -v ${PWD}/cvat/apps/iam/rules:/rules \
openpolicyagent/opa:0.34.2-rootless \
openpolicyagent/opa:0.45.0-rootless \
test /rules -v
```
- or execute OPA directly
```bash
curl -L -o opa https://openpolicyagent.org/downloads/v0.34.2/opa_linux_amd64_static
curl -L -o opa https://openpolicyagent.org/downloads/v0.45.0/opa_linux_amd64_static
chmod +x ./opa
./opa test cvat/apps/iam/rules
```

@ -0,0 +1,560 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
from contextlib import contextmanager
from functools import partial
from http import HTTPStatus
from pathlib import Path
from typing import Optional
from uuid import uuid4
import boto3
import pytest
from cvat_sdk import Client, exceptions
from cvat_sdk.api_client import ApiClient, models
from cvat_sdk.core.client import Config
from cvat_sdk.core.proxies.projects import Project
from cvat_sdk.core.proxies.tasks import ResourceType, Task
from shared.utils.config import (
BASE_URL,
MINIO_ENDPOINT_URL,
MINIO_KEY,
MINIO_SECRET_KEY,
USER_PASS,
post_method,
)
from shared.utils.helpers import generate_image_file
@pytest.fixture
def fxt_image_file(tmp_path: Path):
img_path = tmp_path / "img.png"
with img_path.open("wb") as f:
f.write(generate_image_file(filename=str(img_path), size=(5, 10)).getvalue())
return img_path
def get_common_storage_params():
return {
"provider_type": "AWS_S3_BUCKET",
"credentials_type": "KEY_SECRET_KEY_PAIR",
"key": "minio_access_key",
"secret_key": "minio_secret_key",
"specific_attributes": "endpoint_url=http://minio:9000",
}
def define_s3_client():
s3 = boto3.resource(
"s3",
aws_access_key_id=MINIO_KEY,
aws_secret_access_key=MINIO_SECRET_KEY,
endpoint_url=MINIO_ENDPOINT_URL,
)
return s3.meta.client
class TestUserLimits:
@classmethod
def _create_user(cls, api_client: ApiClient, email: str) -> str:
username = email.split("@", maxsplit=1)[0]
with api_client:
(user, _) = api_client.auth_api.create_register(
models.RegisterSerializerExRequest(
username=username, password1=USER_PASS, password2=USER_PASS, email=email
)
)
api_client.cookies.clear()
return user.username
def _make_client(self) -> Client:
return Client(BASE_URL, config=Config(status_check_period=0.01))
@pytest.fixture(autouse=True)
def setup(self, restore_db_per_function, tmp_path: Path, fxt_image_file: Path):
self.tmp_dir = tmp_path
self.image_file = fxt_image_file
self.client = self._make_client()
self.user = self._create_user(self.client.api_client, email="test_user_limits@localhost")
with self.client:
self.client.login((self.user, USER_PASS))
@pytest.fixture
def fxt_another_client(self) -> Client:
client = self._make_client()
user = self._create_user(self.client.api_client, email="test_user_limits2@localhost")
with client:
client.login((user, USER_PASS))
yield client
_DEFAULT_TASKS_LIMIT = 10
_DEFAULT_PROJECT_TASKS_LIMIT = 5
_DEFAULT_PROJECTS_LIMIT = 3
_DEFAULT_ORGS_LIMIT = 1
_DEFAULT_CLOUD_STORAGES_LIMIT = 10
_TASK_LIMIT_MESSAGE = "user tasks limit reached"
_PROJECT_TASK_LIMIT_MESSAGE = "user project tasks limit reached"
_PROJECTS_LIMIT_MESSAGE = "user projects limit reached"
_ORGS_LIMIT_MESSAGE = "user orgs limit reached"
_CLOUD_STORAGES_LIMIT_MESSAGE = "user cloud storages limit reached"
def _create_task(
self, *, project: Optional[int] = None, client: Optional[Client] = None
) -> Task:
if client is None:
client = self.client
return client.tasks.create_from_data(
spec=models.TaskWriteRequest(
name="test_task",
labels=[models.PatchedLabelRequest(name="cat")] if not project else [],
project_id=project,
),
resource_type=ResourceType.LOCAL,
resources=[str(self.image_file)],
)
def _create_project(self, *, client: Optional[Client] = None) -> Project:
if client is None:
client = self.client
return client.projects.create(models.ProjectWriteRequest(name="test_project"))
def test_can_reach_tasks_limit(self):
for _ in range(self._DEFAULT_TASKS_LIMIT):
self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
self._create_task()
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_importing_backup(self):
for _ in range(self._DEFAULT_TASKS_LIMIT):
task = self._create_task()
backup_filename = self.tmp_dir / "task_backup.zip"
task.download_backup(backup_filename)
with pytest.raises(exceptions.ApiException) as capture:
self.client.tasks.create_from_backup(backup_filename)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_creating_in_project(self):
project = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project)
with pytest.raises(exceptions.ApiException) as capture:
self._create_task(project=project)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECT_TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_creating_in_different_projects(self):
project1 = self._create_project().id
project2 = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project1)
for _ in range(self._DEFAULT_TASKS_LIMIT - self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project2)
with pytest.raises(exceptions.ApiException) as capture:
self._create_task()
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_creating_in_filled_project(self):
project = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project)
for _ in range(self._DEFAULT_TASKS_LIMIT - self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
self._create_task(project=project)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {
self._TASK_LIMIT_MESSAGE,
self._PROJECT_TASK_LIMIT_MESSAGE,
}
def test_can_reach_project_tasks_limit_when_moving_into_filled_project(self):
project = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project)
task = self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
task.update(models.PatchedTaskWriteRequest(project_id=project))
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECT_TASK_LIMIT_MESSAGE}
@pytest.mark.xfail(
raises=AssertionError, reason="only admins can change ownership, but they ignore limits"
)
def test_can_reach_tasks_limit_when_giving_away_to_another_user(
self, fxt_another_client: Client
):
for _ in range(self._DEFAULT_TASKS_LIMIT):
self._create_task(client=fxt_another_client)
task = self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
task.update(
models.PatchedTaskWriteRequest(
owner_id=fxt_another_client.users.retrieve_current_user().id
)
)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECT_TASK_LIMIT_MESSAGE}
@pytest.mark.xfail(
raises=AssertionError, reason="only admins can change ownership, but they ignore limits"
)
def test_can_reach_project_tasks_limit_when_giving_away_to_another_users_filled_project(
self, fxt_another_client: Client
):
project = self._create_project(client=fxt_another_client).id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(client=fxt_another_client, project=project)
for _ in range(self._DEFAULT_TASKS_LIMIT - self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(client=fxt_another_client)
task = self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
task.update(
models.PatchedTaskWriteRequest(
owner_id=fxt_another_client.users.retrieve_current_user().id
)
)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {
self._DEFAULT_TASKS_LIMIT,
self._PROJECT_TASK_LIMIT_MESSAGE,
}
@pytest.mark.xfail(
raises=AssertionError, reason="only admins can change ownership, but they ignore limits"
)
def test_can_reach_projects_limit_when_giving_away_to_another_user(
self, fxt_another_client: Client
):
for _ in range(self._DEFAULT_PROJECTS_LIMIT):
self._create_project(client=fxt_another_client)
project = self._create_project()
with pytest.raises(exceptions.ApiException) as capture:
project.update(
models.PatchedProjectWriteRequest(
owner_id=fxt_another_client.users.retrieve_current_user().id
)
)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECT_TASK_LIMIT_MESSAGE}
def test_can_reach_projects_limit(self):
for _ in range(self._DEFAULT_PROJECTS_LIMIT):
self._create_project()
with pytest.raises(exceptions.ApiException) as capture:
self._create_project()
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECTS_LIMIT_MESSAGE}
def test_can_reach_projects_limit_when_importing_backup(self):
for _ in range(self._DEFAULT_PROJECTS_LIMIT):
project = self._create_project()
backup_filename = self.tmp_dir / (project.name + "_backup.zip")
project.download_backup(backup_filename)
with pytest.raises(exceptions.ApiException) as capture:
self.client.projects.create_from_backup(backup_filename)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECTS_LIMIT_MESSAGE}
def test_can_reach_orgs_limit(self):
for i in range(self._DEFAULT_ORGS_LIMIT):
(_, response) = self.client.api_client.organizations_api.create(
models.OrganizationWriteRequest(slug=f"test_user_orgs_{i}"), _parse_response=False
)
assert response.status == HTTPStatus.CREATED
with pytest.raises(exceptions.ApiException) as capture:
self.client.api_client.organizations_api.create(
models.OrganizationWriteRequest(slug=f"test_user_orgs_{i}"), _parse_response=False
)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._ORGS_LIMIT_MESSAGE}
@pytest.mark.with_external_services
def test_can_reach_cloud_storages_limit(self, request: pytest.FixtureRequest):
storage_params = get_common_storage_params()
# TODO: refactor after https://github.com/opencv/cvat/pull/4819
s3_client = define_s3_client()
def _create_bucket(name: str) -> str:
name = name + str(uuid4())
s3_client.create_bucket(Bucket=name)
request.addfinalizer(partial(s3_client.delete_bucket, Bucket=name))
return name
def _add_storage(idx: int):
response = post_method(
self.user,
"cloudstorages",
{
"display_name": f"test_storage{idx}",
"resource": _create_bucket(f"testbucket{idx}"),
**storage_params,
},
)
return response
for i in range(self._DEFAULT_CLOUD_STORAGES_LIMIT):
response = _add_storage(i)
assert response.status_code == HTTPStatus.CREATED
response = _add_storage(i)
assert response.status_code == HTTPStatus.FORBIDDEN
assert set(response.json()) == {self._CLOUD_STORAGES_LIMIT_MESSAGE}
class TestOrgLimits:
@classmethod
def _create_org(cls, api_client: ApiClient) -> str:
with api_client:
(_, response) = api_client.organizations_api.create(
models.OrganizationWriteRequest(slug="test_org_limits"), _parse_response=False
)
return json.loads(response.data)
def _make_client(self) -> Client:
return Client(BASE_URL, config=Config(status_check_period=0.01))
@pytest.fixture(autouse=True)
def setup(
self, restore_db_per_function, tmp_path: Path, regular_user: str, fxt_image_file: Path
):
self.tmp_dir = tmp_path
self.image_file = fxt_image_file
self.client = self._make_client()
self.user = regular_user
with self.client:
self.client.login((self.user, USER_PASS))
org = self._create_org(self.client.api_client)
self.org = org["id"]
self.org_slug = org["slug"]
with self._patch_client_with_org(self.client):
yield
_DEFAULT_TASKS_LIMIT = 10
_DEFAULT_PROJECT_TASKS_LIMIT = 5
_DEFAULT_PROJECTS_LIMIT = 3
_DEFAULT_CLOUD_STORAGES_LIMIT = 10
_TASK_LIMIT_MESSAGE = "org tasks limit reached"
_PROJECT_TASK_LIMIT_MESSAGE = "org project tasks limit reached"
_PROJECTS_LIMIT_MESSAGE = "org projects limit reached"
_CLOUD_STORAGES_LIMIT_MESSAGE = "org cloud storages limit reached"
@contextmanager
def _patch_client_with_org(self, client: Optional[Client] = None):
if client is None:
client = self.client
new_headers = self.client.api_client.default_headers.copy()
new_headers["X-Organization"] = self.org_slug
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(client.api_client, "default_headers", new_headers)
yield client
@pytest.fixture
def fxt_patch_client_with_org(self):
with self._patch_client_with_org(self.client):
yield
def _create_task(
self, *, project: Optional[int] = None, client: Optional[Client] = None
) -> Task:
if client is None:
client = self.client
return client.tasks.create_from_data(
spec=models.TaskWriteRequest(
name="test_task",
labels=[models.PatchedLabelRequest(name="cat")] if not project else [],
project_id=project,
),
resource_type=ResourceType.LOCAL,
resources=[str(self.image_file)],
)
def _create_project(self, *, client: Optional[Client] = None) -> Project:
if client is None:
client = self.client
return client.projects.create(models.ProjectWriteRequest(name="test_project"))
def test_can_reach_tasks_limit(self):
for _ in range(self._DEFAULT_TASKS_LIMIT):
self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
self._create_task()
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_importing_backup(self):
for _ in range(self._DEFAULT_TASKS_LIMIT):
task = self._create_task()
backup_filename = self.tmp_dir / "task_backup.zip"
task.download_backup(backup_filename)
with pytest.raises(exceptions.ApiException) as capture:
self.client.tasks.create_from_backup(backup_filename)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_creating_in_project(self):
project = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project)
with pytest.raises(exceptions.ApiException) as capture:
self._create_task(project=project)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECT_TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_creating_in_different_projects(self):
project1 = self._create_project().id
project2 = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project1)
for _ in range(self._DEFAULT_TASKS_LIMIT - self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project2)
with pytest.raises(exceptions.ApiException) as capture:
self._create_task()
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._TASK_LIMIT_MESSAGE}
def test_can_reach_tasks_limit_when_creating_in_filled_project(self):
project = self._create_project().id
for _ in range(self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task(project=project)
for _ in range(self._DEFAULT_TASKS_LIMIT - self._DEFAULT_PROJECT_TASKS_LIMIT):
self._create_task()
with pytest.raises(exceptions.ApiException) as capture:
self._create_task(project=project)
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {
self._TASK_LIMIT_MESSAGE,
self._PROJECT_TASK_LIMIT_MESSAGE,
}
def test_can_reach_projects_limit(self):
for _ in range(self._DEFAULT_PROJECTS_LIMIT):
self._create_project()
with pytest.raises(exceptions.ApiException) as capture:
self._create_project()
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECTS_LIMIT_MESSAGE}
def test_can_reach_projects_limit_when_importing_backup(self):
for _ in range(self._DEFAULT_PROJECTS_LIMIT):
project = self._create_project()
backup_filename = self.tmp_dir / "test_project_backup.zip"
project.download_backup(str(backup_filename))
with pytest.raises(exceptions.ApiException) as capture:
self.client.projects.create_from_backup(str(backup_filename))
assert capture.value.status == HTTPStatus.FORBIDDEN
assert set(json.loads(capture.value.body)) == {self._PROJECTS_LIMIT_MESSAGE}
@pytest.mark.with_external_services
def test_can_reach_cloud_storages_limit(self, request: pytest.FixtureRequest):
storage_params = get_common_storage_params()
# TODO: refactor after https://github.com/opencv/cvat/pull/4819
s3_client = define_s3_client()
def _create_bucket(name: str) -> str:
name = name + str(uuid4())
s3_client.create_bucket(Bucket=name)
request.addfinalizer(partial(s3_client.delete_bucket, Bucket=name))
return name
def _add_storage(idx: int):
response = post_method(
self.user,
"cloudstorages",
{
"display_name": f"test_storage{idx}",
"resource": _create_bucket(f"testbucket{idx}"),
**storage_params,
},
org_id=self.org,
)
return response
for i in range(self._DEFAULT_CLOUD_STORAGES_LIMIT):
response = _add_storage(i)
assert response.status_code == HTTPStatus.CREATED
response = _add_storage(i)
assert response.status_code == HTTPStatus.FORBIDDEN
assert set(response.json()) == {self._CLOUD_STORAGES_LIMIT_MESSAGE}

@ -10,13 +10,15 @@ import zipfile
from copy import deepcopy
from http import HTTPStatus
from io import BytesIO
from itertools import groupby, product
from itertools import product
from time import sleep
from typing import Dict, Optional
import pytest
from cvat_sdk.api_client import ApiClient, Configuration, models
from deepdiff import DeepDiff
from shared.utils.config import get_method, make_api_client, patch_method
from shared.utils.config import BASE_URL, USER_PASS, get_method, make_api_client, patch_method
from .utils import export_dataset
@ -300,39 +302,7 @@ class TestPostProjects:
spec = {"name": f"test {username} tries to create a project"}
self._test_create_project_201(username, spec)
def test_if_user_cannot_have_more_than_3_projects(self, projects, find_users):
users = find_users(privilege="user")
user_id, user_projects = next(
(user_id, len(list(projects)))
for user_id, projects in groupby(projects, lambda a: a["owner"]["id"])
if len(list(projects)) < 3
)
user = users[user_id]
for i in range(1, 4 - user_projects):
spec = {
"name": f'test: {user["username"]} tries to create a project number {user_projects + i}'
}
self._test_create_project_201(user["username"], spec)
spec = {"name": f'test {user["username"]} tries to create more than 3 projects'}
self._test_create_project_403(user["username"], spec)
@pytest.mark.parametrize("privilege", ("admin", "business"))
def test_if_user_can_have_more_than_3_projects(self, find_users, privilege):
privileged_users = find_users(privilege=privilege)
assert len(privileged_users)
user = privileged_users[0]
for i in range(1, 5):
spec = {
"name": f'test: {user["username"]} with privilege {privilege} tries to create a project number {i}'
}
self._test_create_project_201(user["username"], spec)
def test_if_org_worker_cannot_crate_project(self, find_users):
def test_if_org_worker_cannot_create_project(self, find_users):
workers = find_users(role="worker")
worker = next(u for u in workers if u["org"])
@ -343,16 +313,52 @@ class TestPostProjects:
self._test_create_project_403(worker["username"], spec, org_id=worker["org"])
@pytest.mark.parametrize("role", ("supervisor", "maintainer", "owner"))
def test_if_org_role_can_create_project(self, find_users, role):
privileged_users = find_users(role=role)
assert len(privileged_users)
def test_if_org_role_can_create_project(self, role, admin_user):
# We can hit org or user limits here, so we create a new org and users
user = self._create_user(
ApiClient(configuration=Configuration(BASE_URL)), email="test_org_roles@localhost"
)
user = next(u for u in privileged_users if u["org"])
if role != "owner":
org = self._create_org(make_api_client(admin_user), members={user["email"]: role})
else:
org = self._create_org(make_api_client(user["username"]))
spec = {
"name": f'test: worker {user["username"]} creating a project for his organization',
}
self._test_create_project_201(user["username"], spec, org_id=user["org"])
self._test_create_project_201(user["username"], spec, org_id=org)
@classmethod
def _create_user(cls, api_client: ApiClient, email: str) -> str:
username = email.split("@", maxsplit=1)[0]
with api_client:
(_, response) = api_client.auth_api.create_register(
models.RegisterSerializerExRequest(
username=username, password1=USER_PASS, password2=USER_PASS, email=email
)
)
api_client.cookies.clear()
return json.loads(response.data)
@classmethod
def _create_org(cls, api_client: ApiClient, members: Optional[Dict[str, str]] = None) -> str:
with api_client:
(_, response) = api_client.organizations_api.create(
models.OrganizationWriteRequest(slug="test_org_roles"), _parse_response=False
)
org = json.loads(response.data)["id"]
for email, role in (members or {}).items():
api_client.invitations_api.create(
models.InvitationWriteRequest(role=role, email=email),
org_id=org,
_parse_response=False,
)
return org
def _check_cvat_for_video_project_annotations_meta(content, values_to_be_checked):

@ -378,3 +378,11 @@ def admin_user(users):
if user["is_superuser"] and user["is_active"]:
return user["username"]
raise Exception("Can't find any admin user in the test DB")
@pytest.fixture(scope="session")
def regular_user(users):
for user in users:
if not user["is_superuser"] and user["is_active"]:
return user["username"]
raise Exception("Can't find any regular user in the test DB")

Loading…
Cancel
Save