未验证 提交 ec3e1f34 编写于 作者: M Maxim Zhiltsov 提交者: GitHub

Better reporting for user limits (#5225)

- Added explanatory messages for actions denied for user limits
- Fixed few rules and checks
- Upgraded OPA version
上级 aa4980ee
......@@ -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
......
此差异已折叠。
......@@ -23,9 +23,6 @@ import data.organizations
# "id": <num>,
# "owner": { "id": <num> },
# "organization": { "id": <num> } or null,
# "user": {
# "num_resources": <num>
# }
# }
# }
......
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)
}
......
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 {
......
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
\ No newline at end of file
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
......
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
......
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
......
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
......
# 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
}
# 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)
from django.apps import AppConfig
class LimitManagerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'limit_manager'
# 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}")
# 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
```
此差异已折叠。
......@@ -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")
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册