mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 23:34:27 +00:00
feat(Tenants, Memberships): PRWLR-4719 Add memberships endpoints and security changes (#44)
* feat(SimpleJWT): PRWLR-4717 add djangorestframework-simplejwt dep * feat(SimpleJWT): PRWLR-4717 add basic configuration and env variables * feat(Users): PRWLR-4717 add model and security constraints * feat(Users): PRWLR-4717 add serializers * feat(Users): PRWLR-4717 add views * test(Conftest): PRWLR-4717 add user and authenticated client fixtures * fix(Unit tests): PRWLR-4717 add automated authentication to all unit tests * fix(Authentication): PRWLR-4717 add authentication class and update tests * test(Users): PRWLR-4717 add unit tests * test(Users): PRWLR-4717 add integration tests * chore(Schema): PRWLR-4717 update API schema * fix(User): PRWLR-4717 fix password validation * feat(Validators): PRWLR-4717 add MaxLength password validator * fix(User): PRWLR-4717 update User model to delete admin fields * chore(Serializers): PRWLR-4717 add docstrings and update serializers * chore(Fixtures): PRWLR-4717 add dev user * feat(Memberships): PRWLR-4719 add membership.Roles postgres enums * feat(Memberships): PRWLR-4719 add model * feat(Tenants): PRWLR-4719 add owner membership when creating a new one * chore(Users): PRWLR-4717 raise DRF NotFound instead of returning response * chore(Deps): PRWLR-4719 add drf-nested-routers * feat(Memberships): PRWLR-4719 add serializers, urls and views * feat(Tenants): PRWLR-4719 add RLS based on memberships * chore(Views): PRWLR-4719 unify tenant related views dispatch methods * test(Tenants): PRWLR-4719 add membership to test user when creating tenants * test(Users): PRWLR-4719 add name field to model * chore(Deps): PRWLR-4719 add drf-nested-routers * feat(Memberships): PRWLR-4719 separate membership endpoints * feat(Memberships): PRWLR-4719 add filters * test(Memberships): PRWLR-4719 add unit tests * test(Tenants): PRWLR-4719 add membership unit tests * chore(API): PRWLR-4719 update spec * chore(API): PRWLR-4719 update Django dev fixtures * test(Memberships): PRWLR-4719 add more unit tests * chore(Memberships): PRWLR-4719 move /memberships to /users/me/ * feat(Users, Tenants, Memberships): PRWLR-4719 update responses and routes * feat(Memberships): PRWLR-4719 adjust tenant membership view results under membership role * chore(Scans): PRWLR-4719 rename django fixture
This commit is contained in:
1 parent
4a881fd2fd
commit
4c83351b26
19 files changed
+1195
-46
No files matched your search
@@ -61,3 +61,16 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
tenant_id = self.request.headers.get("X-Tenant-ID")
|
||||
context["tenant_id"] = tenant_id
|
||||
return context
|
||||
|
||||
|
||||
class BaseTenantViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
user_id = str(request.user.id)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"SELECT set_config('api.user_id', '{user_id}', TRUE);")
|
||||
return super().initial(request, *args, **kwargs)
|
||||
@@ -18,6 +18,7 @@ DB_PROWLER_PASSWORD = (
|
||||
)
|
||||
TASK_RUNNER_DB_TABLE = "django_celery_results_taskresult"
|
||||
POSTGRES_TENANT_VAR = "api.tenant_id"
|
||||
POSTGRES_USER_VAR = "api.user_id"
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -134,6 +135,18 @@ def register_enum(apps, schema_editor, enum_class): # noqa: F841
|
||||
register_adapter(enum_class, enum_adapter)
|
||||
|
||||
|
||||
# Postgres enum definition for member role
|
||||
|
||||
|
||||
class MemberRoleEnum(EnumType):
|
||||
enum_type_name = "member_role"
|
||||
|
||||
|
||||
class MemberRoleEnumField(PostgresEnumField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__("member_role", *args, **kwargs)
|
||||
|
||||
|
||||
# Postgres enum definition for Provider.provider
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.db_utils import ProviderEnumField
|
||||
from api.models import (
|
||||
Membership,
|
||||
Provider,
|
||||
Resource,
|
||||
ResourceTag,
|
||||
@@ -107,6 +108,27 @@ class TenantFilter(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class MembershipFilter(FilterSet):
|
||||
date_joined = DateFilter(field_name="date_joined", lookup_expr="date")
|
||||
role = CharFilter(method="filter_role")
|
||||
|
||||
def filter_role(self, queryset, name, value):
|
||||
return enum_filter(
|
||||
queryset,
|
||||
value,
|
||||
enum_choices=Membership.RoleChoices,
|
||||
lookup_field="role",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = {
|
||||
"tenant": ["exact"],
|
||||
"role": ["exact"],
|
||||
"date_joined": ["date", "gte", "lte"],
|
||||
}
|
||||
|
||||
|
||||
class ProviderFilter(FilterSet):
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
|
||||
|
||||
@@ -10,5 +10,17 @@
|
||||
"is_active": true,
|
||||
"date_joined": "2024-09-17T09:04:20.850Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.user",
|
||||
"pk": "b6493a3a-c997-489b-8b99-278bf74de9f6",
|
||||
"fields": {
|
||||
"password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=",
|
||||
"last_login": null,
|
||||
"username": "prowler_dev_2",
|
||||
"email": "dev2@prowler.com",
|
||||
"is_active": true,
|
||||
"date_joined": "2024-09-18T09:04:20.850Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -16,5 +16,35 @@
|
||||
"updated_at": "2024-03-21T23:00:00Z",
|
||||
"name": "Tenant1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.membership",
|
||||
"pk": "2b0db93a-7e0b-4edf-a851-ea448676b7eb",
|
||||
"fields": {
|
||||
"user": "8b38e2eb-6689-4f1e-a4ba-95b275130200",
|
||||
"tenant": "0412980b-06e3-436a-ab98-3c9b1d0333d3",
|
||||
"role": "owner",
|
||||
"date_joined": "2024-09-19T11:03:59.712Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.membership",
|
||||
"pk": "797d7cee-abc9-4598-98bb-4bf4bfb97f27",
|
||||
"fields": {
|
||||
"user": "8b38e2eb-6689-4f1e-a4ba-95b275130200",
|
||||
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
|
||||
"role": "owner",
|
||||
"date_joined": "2024-09-19T11:03:59.712Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.membership",
|
||||
"pk": "dea37563-7009-4dcf-9f18-25efb41462a7",
|
||||
"fields": {
|
||||
"user": "b6493a3a-c997-489b-8b99-278bf74de9f6",
|
||||
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
|
||||
"role": "member",
|
||||
"date_joined": "2024-09-19T11:03:59.712Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
File renamed without changes.
File renamed without changes.
@@ -24,6 +24,8 @@ from uuid6 import uuid7
|
||||
import api.rls
|
||||
from api.db_utils import (
|
||||
PostgresEnumMigration,
|
||||
MemberRoleEnumField,
|
||||
MemberRoleEnum,
|
||||
ProviderEnum,
|
||||
ProviderEnumField,
|
||||
ScanTriggerEnum,
|
||||
@@ -35,6 +37,7 @@ from api.db_utils import (
|
||||
DB_PROWLER_PASSWORD,
|
||||
TASK_RUNNER_DB_TABLE,
|
||||
POSTGRES_TENANT_VAR,
|
||||
POSTGRES_USER_VAR,
|
||||
)
|
||||
from api.models import (
|
||||
Provider,
|
||||
@@ -43,10 +46,15 @@ from api.models import (
|
||||
Finding,
|
||||
StatusChoices,
|
||||
SeverityChoices,
|
||||
Membership,
|
||||
)
|
||||
|
||||
DB_NAME = settings.DATABASES["default"]["NAME"]
|
||||
|
||||
MemberRoleEnumMigration = PostgresEnumMigration(
|
||||
enum_name="member_role",
|
||||
enum_values=tuple(role[0] for role in Membership.RoleChoices.choices),
|
||||
)
|
||||
|
||||
ProviderEnumMigration = PostgresEnumMigration(
|
||||
enum_name="provider",
|
||||
@@ -116,13 +124,6 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
@@ -132,6 +133,20 @@ class Migration(migrations.Migration):
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
max_length=150,
|
||||
validators=[django.core.validators.MinLengthValidator(3)],
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
@@ -189,6 +204,146 @@ class Migration(migrations.Migration):
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE tenants TO {DB_PROWLER_USER};
|
||||
"""
|
||||
),
|
||||
# Create and register MemberRoleEnum type
|
||||
migrations.RunPython(
|
||||
MemberRoleEnumMigration.create_enum_type,
|
||||
reverse_code=MemberRoleEnumMigration.drop_enum_type,
|
||||
),
|
||||
migrations.RunPython(partial(register_enum, enum_class=MemberRoleEnum)),
|
||||
migrations.CreateModel(
|
||||
name="Membership",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
MemberRoleEnumField(
|
||||
choices=[("scheduled", "Scheduled"), ("member", "Member")],
|
||||
default="member",
|
||||
),
|
||||
),
|
||||
("date_joined", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
related_query_name="membership",
|
||||
to="api.tenant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="memberships",
|
||||
related_query_name="membership",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "memberships",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="membership",
|
||||
constraint=api.rls.BaseSecurityConstraint(
|
||||
name="statements_on_membership",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="membership",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "tenant"),
|
||||
name="unique_resources_by_membership",
|
||||
),
|
||||
),
|
||||
# Enable tenants RLS based on memberships
|
||||
migrations.RunSQL(f"""
|
||||
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy for SELECT
|
||||
CREATE POLICY "{DB_PROWLER_USER}_tenants_select"
|
||||
ON tenants
|
||||
FOR SELECT
|
||||
TO {DB_PROWLER_USER}
|
||||
USING (
|
||||
CASE
|
||||
WHEN (current_setting('{POSTGRES_USER_VAR}', true) IS NOT NULL AND current_setting('{POSTGRES_USER_VAR}', true) <> '') THEN
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships
|
||||
WHERE
|
||||
memberships.tenant_id = tenants.id
|
||||
AND memberships.user_id = current_setting('{POSTGRES_USER_VAR}', true)::uuid
|
||||
)
|
||||
WHEN (current_setting('{POSTGRES_TENANT_VAR}', true) IS NOT NULL AND current_setting('{POSTGRES_TENANT_VAR}', true) <> '') THEN
|
||||
tenants.id = current_setting('{POSTGRES_TENANT_VAR}', true)::uuid
|
||||
ELSE
|
||||
FALSE
|
||||
END
|
||||
);
|
||||
|
||||
-- Policy for UPDATE
|
||||
CREATE POLICY "{DB_PROWLER_USER}_tenants_update"
|
||||
ON tenants
|
||||
FOR UPDATE
|
||||
TO {DB_PROWLER_USER}
|
||||
USING (
|
||||
CASE
|
||||
WHEN (current_setting('{POSTGRES_USER_VAR}', true) IS NOT NULL AND current_setting('{POSTGRES_USER_VAR}', true) <> '') THEN
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships
|
||||
WHERE
|
||||
memberships.tenant_id = tenants.id
|
||||
AND memberships.user_id = current_setting('{POSTGRES_USER_VAR}', true)::uuid
|
||||
)
|
||||
WHEN (current_setting('{POSTGRES_TENANT_VAR}', true) IS NOT NULL AND current_setting('{POSTGRES_TENANT_VAR}', true) <> '') THEN
|
||||
tenants.id = current_setting('{POSTGRES_TENANT_VAR}', true)::uuid
|
||||
ELSE
|
||||
FALSE
|
||||
END
|
||||
);
|
||||
|
||||
-- Policy for DELETE
|
||||
CREATE POLICY "{DB_PROWLER_USER}_tenants_delete"
|
||||
ON tenants
|
||||
FOR DELETE
|
||||
TO {DB_PROWLER_USER}
|
||||
USING (
|
||||
CASE
|
||||
WHEN (current_setting('{POSTGRES_USER_VAR}', true) IS NOT NULL AND current_setting('{POSTGRES_USER_VAR}', true) <> '') THEN
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships
|
||||
WHERE
|
||||
memberships.tenant_id = tenants.id
|
||||
AND memberships.user_id = current_setting('{POSTGRES_USER_VAR}', true)::uuid
|
||||
)
|
||||
WHEN (current_setting('{POSTGRES_TENANT_VAR}', true) IS NOT NULL AND current_setting('{POSTGRES_TENANT_VAR}', true) <> '') THEN
|
||||
tenants.id = current_setting('{POSTGRES_TENANT_VAR}', true)::uuid
|
||||
ELSE
|
||||
FALSE
|
||||
END
|
||||
);
|
||||
|
||||
-- Policy for INSERT
|
||||
CREATE POLICY "{DB_PROWLER_USER}_tenants_insert"
|
||||
ON tenants
|
||||
FOR INSERT
|
||||
TO {DB_PROWLER_USER}
|
||||
WITH CHECK (true);
|
||||
"""),
|
||||
# Create and register ProviderEnum type
|
||||
migrations.RunPython(
|
||||
ProviderEnumMigration.create_enum_type,
|
||||
|
||||
@@ -15,6 +15,7 @@ from psqlextra.types import PostgresPartitioningMethod
|
||||
from uuid6 import uuid7
|
||||
|
||||
from api.db_utils import (
|
||||
MemberRoleEnumField,
|
||||
enum_to_choices,
|
||||
ProviderEnumField,
|
||||
StateEnumField,
|
||||
@@ -27,6 +28,9 @@ from api.db_utils import (
|
||||
from api.exceptions import ModelValidationError
|
||||
from api.rls import (
|
||||
RowLevelSecurityProtectedModel,
|
||||
)
|
||||
from api.rls import (
|
||||
Tenant,
|
||||
RowLevelSecurityConstraint,
|
||||
BaseSecurityConstraint,
|
||||
)
|
||||
@@ -39,7 +43,7 @@ class StatusChoices(models.TextChoices):
|
||||
"""
|
||||
This list is based on the finding status in the Prowler CLI.
|
||||
|
||||
However it adds another state, MUTED, which is not in the CLI.
|
||||
However, it adds another state, MUTED, which is not in the CLI.
|
||||
"""
|
||||
|
||||
FAIL = "FAIL", _("Fail")
|
||||
@@ -59,6 +63,7 @@ class StateChoices(models.TextChoices):
|
||||
|
||||
class User(AbstractBaseUser):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
name = models.CharField(max_length=150, validators=[MinLengthValidator(3)])
|
||||
username = models.CharField(
|
||||
max_length=150, unique=True, validators=[UnicodeUsernameValidator()]
|
||||
)
|
||||
@@ -67,7 +72,7 @@ class User(AbstractBaseUser):
|
||||
date_joined = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
|
||||
USERNAME_FIELD = "username"
|
||||
REQUIRED_FIELDS = ["email"]
|
||||
REQUIRED_FIELDS = ["name", "email"]
|
||||
|
||||
objects = CustomUserManager()
|
||||
|
||||
@@ -82,6 +87,42 @@ class User(AbstractBaseUser):
|
||||
]
|
||||
|
||||
|
||||
class Membership(models.Model):
|
||||
class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
MEMBER = "member", _("Member")
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="memberships",
|
||||
related_query_name="membership",
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
Tenant,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="memberships",
|
||||
related_query_name="membership",
|
||||
)
|
||||
role = MemberRoleEnumField(choices=RoleChoices.choices, default=RoleChoices.MEMBER)
|
||||
date_joined = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "memberships"
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "tenant"),
|
||||
name="unique_resources_by_membership",
|
||||
),
|
||||
BaseSecurityConstraint(
|
||||
name="statements_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Provider(RowLevelSecurityProtectedModel):
|
||||
class ProviderChoices(models.TextChoices):
|
||||
AWS = "aws", _("AWS")
|
||||
|
||||
@@ -24,7 +24,6 @@ class Tenant(models.Model):
|
||||
db_table = "tenants"
|
||||
|
||||
|
||||
# TODO Add abstract class for non-RLS models
|
||||
class RowLevelSecurityConstraint(models.BaseConstraint):
|
||||
"""
|
||||
Model constraint to enforce row-level security on a tenant based model, in addition to the least privileges.
|
||||
|
||||
@@ -701,8 +701,8 @@ paths:
|
||||
- service
|
||||
- tags
|
||||
- provider
|
||||
- url
|
||||
- findings
|
||||
- url
|
||||
- type
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
@@ -956,8 +956,8 @@ paths:
|
||||
- service
|
||||
- tags
|
||||
- provider
|
||||
- url
|
||||
- findings
|
||||
- url
|
||||
- type
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
@@ -1645,6 +1645,111 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: No response body
|
||||
/api/v1/tenants/{tenant_pk}/members:
|
||||
get:
|
||||
operationId: tenants_members_list
|
||||
description: List the membership details of users in a tenant you are a part
|
||||
of.
|
||||
summary: List tenant memberships
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[Membership]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- user
|
||||
- tenant
|
||||
- role
|
||||
- date_joined
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- name: filter[search]
|
||||
required: false
|
||||
in: query
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- name: page[size]
|
||||
required: false
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- id
|
||||
- -id
|
||||
- user
|
||||
- -user
|
||||
- tenant
|
||||
- -tenant
|
||||
- role
|
||||
- -role
|
||||
- date_joined
|
||||
- -date_joined
|
||||
explode: false
|
||||
- in: path
|
||||
name: tenant_pk
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Tenant ID
|
||||
required: true
|
||||
tags:
|
||||
- Tenant
|
||||
security:
|
||||
- basicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedMembershipList'
|
||||
description: ''
|
||||
/api/v1/tenants/{tenant_pk}/members/{id}:
|
||||
delete:
|
||||
operationId: tenants_members_destroy
|
||||
description: Delete the membership details of users in a tenant. You need to
|
||||
be one of the owners to delete a membership that is not yours. If you are
|
||||
the last owner of a tenant, you cannot delete your own membership.
|
||||
summary: Delete tenant memberships
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this membership.
|
||||
required: true
|
||||
- in: path
|
||||
name: tenant_pk
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
required: true
|
||||
tags:
|
||||
- Tenant
|
||||
security:
|
||||
- basicAuth: []
|
||||
responses:
|
||||
'204':
|
||||
description: No response body
|
||||
/api/v1/users:
|
||||
post:
|
||||
operationId: users_create
|
||||
@@ -1743,6 +1848,7 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- name
|
||||
- username
|
||||
- email
|
||||
- date_joined
|
||||
@@ -1760,6 +1866,139 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
description: ''
|
||||
/api/v1/users/me/memberships:
|
||||
get:
|
||||
operationId: users_me_memberships_list
|
||||
description: Retrieve a list of all user memberships with options for filtering
|
||||
by various criteria.
|
||||
summary: List user memberships
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[Membership]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- user
|
||||
- tenant
|
||||
- role
|
||||
- date_joined
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: query
|
||||
name: filter[date_joined]
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[date_joined__date]
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: filter[date_joined__gte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[date_joined__lte]
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: filter[role]
|
||||
schema:
|
||||
type: string
|
||||
- name: filter[search]
|
||||
required: false
|
||||
in: query
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: filter[tenant]
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- name: page[size]
|
||||
required: false
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- tenant
|
||||
- -tenant
|
||||
- role
|
||||
- -role
|
||||
- date_joined
|
||||
- -date_joined
|
||||
explode: false
|
||||
tags:
|
||||
- User
|
||||
security:
|
||||
- basicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedMembershipList'
|
||||
description: ''
|
||||
/api/v1/users/me/memberships/{id}:
|
||||
get:
|
||||
operationId: users_me_memberships_retrieve
|
||||
description: Fetch detailed information about a specific user membership by
|
||||
their ID.
|
||||
summary: Retrieve membership data from the user
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[Membership]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- user
|
||||
- tenant
|
||||
- role
|
||||
- date_joined
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
required: true
|
||||
tags:
|
||||
- User
|
||||
security:
|
||||
- basicAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
description: ''
|
||||
components:
|
||||
schemas:
|
||||
DelayedTask:
|
||||
@@ -1930,6 +2169,104 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- findings
|
||||
Membership:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MembershipTypeEnum'
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
role:
|
||||
enum:
|
||||
- owner
|
||||
- member
|
||||
type: string
|
||||
description: |-
|
||||
* `owner` - Owner
|
||||
* `member` - Member
|
||||
date_joined:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- role
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- User
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
tenant:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- Tenant
|
||||
title: Resource Type Name
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common
|
||||
attributes and relationships.
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
required:
|
||||
- data
|
||||
description: The identifier of the related object.
|
||||
title: Resource Identifier
|
||||
required:
|
||||
- user
|
||||
- tenant
|
||||
MembershipResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Membership'
|
||||
required:
|
||||
- data
|
||||
MembershipTypeEnum:
|
||||
type: string
|
||||
enum:
|
||||
- Membership
|
||||
PaginatedFindingList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1939,6 +2276,15 @@ components:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
required:
|
||||
- data
|
||||
PaginatedMembershipList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Membership'
|
||||
required:
|
||||
- data
|
||||
PaginatedProviderList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2105,6 +2451,10 @@ components:
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 150
|
||||
password:
|
||||
type: string
|
||||
writeOnly: true
|
||||
@@ -2115,6 +2465,7 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 254
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
required:
|
||||
- data
|
||||
@@ -2796,6 +3147,10 @@ components:
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
minLength: 3
|
||||
username:
|
||||
type: string
|
||||
pattern: ^[\w.@+-]+$
|
||||
@@ -2809,6 +3164,7 @@ components:
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- name
|
||||
- username
|
||||
- email
|
||||
UserCreate:
|
||||
@@ -2826,6 +3182,10 @@ components:
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
minLength: 3
|
||||
username:
|
||||
type: string
|
||||
pattern: ^[\w.@+-]+$
|
||||
@@ -2838,6 +3198,7 @@ components:
|
||||
format: email
|
||||
maxLength: 254
|
||||
required:
|
||||
- name
|
||||
- username
|
||||
- password
|
||||
- email
|
||||
@@ -2860,6 +3221,10 @@ components:
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 150
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -2875,6 +3240,7 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 254
|
||||
required:
|
||||
- name
|
||||
- username
|
||||
- password
|
||||
- email
|
||||
@@ -2913,6 +3279,10 @@ components:
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 150
|
||||
minLength: 3
|
||||
password:
|
||||
type: string
|
||||
writeOnly: true
|
||||
@@ -2921,6 +3291,7 @@ components:
|
||||
format: email
|
||||
maxLength: 254
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
UserUpdateResponse:
|
||||
type: object
|
||||
|
||||
@@ -24,6 +24,7 @@ def test_basic_authentication(providers_fixture, tenant_header):
|
||||
"data": {
|
||||
"type": "User",
|
||||
"attributes": {
|
||||
"name": "test",
|
||||
"username": test_user,
|
||||
"password": test_password,
|
||||
"email": "thisisnotimportant@prowler.com",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -5,9 +6,14 @@ import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from api.models import User, Provider, Scan
|
||||
from api.models import User, Membership, Provider, Scan
|
||||
from api.rls import Tenant
|
||||
from conftest import API_JSON_CONTENT_TYPE, NO_TENANT_HTTP_STATUS
|
||||
from conftest import (
|
||||
API_JSON_CONTENT_TYPE,
|
||||
NO_TENANT_HTTP_STATUS,
|
||||
TEST_USER,
|
||||
TEST_PASSWORD,
|
||||
)
|
||||
|
||||
TODAY = str(datetime.today().date())
|
||||
|
||||
@@ -18,11 +24,11 @@ class TestUserViewSet:
|
||||
response = authenticated_client.get(reverse("user-list"))
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
def test_users_retrieve_not_allowed(self, authenticated_client, create_test_user):
|
||||
def test_users_retrieve(self, authenticated_client, create_test_user):
|
||||
response = authenticated_client.get(
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_users_me(self, authenticated_client, create_test_user):
|
||||
response = authenticated_client.get(reverse("user-me"))
|
||||
@@ -34,6 +40,7 @@ class TestUserViewSet:
|
||||
|
||||
def test_users_create(self, client):
|
||||
valid_user_payload = {
|
||||
"name": "test",
|
||||
"username": "newuser",
|
||||
"password": "newpassword123",
|
||||
"email": "newuser@example.com",
|
||||
@@ -47,6 +54,7 @@ class TestUserViewSet:
|
||||
|
||||
def test_users_invalid_create(self, client):
|
||||
invalid_user_payload = {
|
||||
"name": "test",
|
||||
"username": "theusernameisfine",
|
||||
"password": "thepasswordisfine123",
|
||||
"email": "invalidemail",
|
||||
@@ -87,6 +95,7 @@ class TestUserViewSet:
|
||||
)
|
||||
def test_users_create_invalid_passwords(self, authenticated_client, password):
|
||||
invalid_user_payload = {
|
||||
"name": "test",
|
||||
"username": "thisisfine",
|
||||
"password": password,
|
||||
"email": "thisisafineemail@prowler.com",
|
||||
@@ -231,6 +240,7 @@ class TestUserViewSet:
|
||||
self, client, attribute_key, attribute_value, error_field
|
||||
):
|
||||
invalid_payload = {
|
||||
"name": "test",
|
||||
"username": "testuser",
|
||||
"password": "testpassword",
|
||||
"email": "test@example.com",
|
||||
@@ -270,6 +280,33 @@ class TestTenantViewSet:
|
||||
"updated_at": "2023-01-06",
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def extra_users(self, tenants_fixture):
|
||||
_, tenant2 = tenants_fixture
|
||||
user2 = User.objects.create_user(
|
||||
name="testing2",
|
||||
username="testing2",
|
||||
password=TEST_PASSWORD,
|
||||
email="testing2@gmail.com",
|
||||
)
|
||||
user3 = User.objects.create_user(
|
||||
name="testing3",
|
||||
username="testing3",
|
||||
password=TEST_PASSWORD,
|
||||
email="testing3@gmail.com",
|
||||
)
|
||||
membership2 = Membership.objects.create(
|
||||
user=user2,
|
||||
tenant=tenant2,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
membership3 = Membership.objects.create(
|
||||
user=user3,
|
||||
tenant=tenant2,
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
)
|
||||
return (user2, membership2), (user3, membership3)
|
||||
|
||||
def test_tenants_list(self, authenticated_client, tenants_fixture):
|
||||
response = authenticated_client.get(reverse("tenant-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -453,6 +490,267 @@ class TestTenantViewSet:
|
||||
assert len(response.json()["data"]) == 2
|
||||
assert response.json()["data"][0]["attributes"]["name"] == tenant2.name
|
||||
|
||||
def test_tenants_list_memberships_as_owner(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2 = tenants_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Test user + 2 extra users for tenant 2
|
||||
assert len(response.json()["data"]) == 3
|
||||
|
||||
def test_tenants_list_memberships_as_member(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2 = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
base_64_credentials = base64.b64encode(
|
||||
f"{user3.username}:{TEST_PASSWORD}".encode()
|
||||
).decode()
|
||||
response = authenticated_client.get(
|
||||
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
|
||||
headers={"Authorization": f"Basic {base_64_credentials}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# User is a member and can only see its own membership
|
||||
assert len(response.json()["data"]) == 1
|
||||
assert response.json()["data"][0]["id"] == str(membership3.id)
|
||||
|
||||
def test_tenants_delete_own_membership_as_member(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
tenant1, _ = tenants_fixture
|
||||
membership = Membership.objects.get(tenant=tenant1, user__username=TEST_USER)
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant1.id, "pk": membership.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Membership.objects.filter(id=membership.id).exists()
|
||||
|
||||
def test_tenants_delete_own_membership_as_owner(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
# With extra_users, tenant2 has 2 owners
|
||||
_, tenant2 = tenants_fixture
|
||||
user_membership = Membership.objects.get(
|
||||
tenant=tenant2, user__username=TEST_USER
|
||||
)
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": user_membership.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Membership.objects.filter(id=user_membership.id).exists()
|
||||
|
||||
def test_tenants_delete_own_membership_as_last_owner(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
_, tenant2 = tenants_fixture
|
||||
user_membership = Membership.objects.get(
|
||||
tenant=tenant2, user__username=TEST_USER
|
||||
)
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": user_membership.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Membership.objects.filter(id=user_membership.id).exists()
|
||||
|
||||
def test_tenants_delete_another_membership_as_owner(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2 = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Membership.objects.filter(id=membership3.id).exists()
|
||||
|
||||
def test_tenants_delete_another_membership_as_member(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2 = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
# Downgrade membership role manually
|
||||
user_membership = Membership.objects.get(
|
||||
tenant=tenant2, user__username=TEST_USER
|
||||
)
|
||||
user_membership.role = Membership.RoleChoices.MEMBER
|
||||
user_membership.save()
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Membership.objects.filter(id=membership3.id).exists()
|
||||
|
||||
def test_tenants_list_memberships_not_member_of_tenant(self, authenticated_client):
|
||||
# Create a tenant the user is not a member of
|
||||
tenant3 = Tenant.objects.create(name="Tenant Three")
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant3.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMembershipViewSet:
|
||||
def test_memberships_list(self, authenticated_client, tenants_fixture):
|
||||
user_id = authenticated_client.user.pk
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(tenants_fixture)
|
||||
|
||||
def test_memberships_retrieve(self, authenticated_client, tenants_fixture):
|
||||
user_id = authenticated_client.user.pk
|
||||
list_response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
membership = list_response.json()["data"][0]
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse(
|
||||
"user-membership-detail",
|
||||
kwargs={"user_pk": user_id, "pk": membership["id"]},
|
||||
),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()["data"]["relationships"]["tenant"]["data"]["id"]
|
||||
== membership["relationships"]["tenant"]["data"]["id"]
|
||||
)
|
||||
assert (
|
||||
response.json()["data"]["relationships"]["user"]["data"]["id"]
|
||||
== membership["relationships"]["user"]["data"]["id"]
|
||||
)
|
||||
|
||||
def test_memberships_invalid_retrieve(self, authenticated_client, tenant_header):
|
||||
user_id = authenticated_client.user.pk
|
||||
response = authenticated_client.get(
|
||||
reverse(
|
||||
"user-membership-detail",
|
||||
kwargs={
|
||||
"user_pk": user_id,
|
||||
"pk": "b91c5eff-13f5-469c-9fd8-917b3a3037b6",
|
||||
},
|
||||
),
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_count",
|
||||
[
|
||||
("role", "owner", 1),
|
||||
("role", "member", 1),
|
||||
("date_joined", TODAY, 2),
|
||||
("date_joined.gte", "2024-01-01", 2),
|
||||
("date_joined.lte", "2024-01-01", 0),
|
||||
],
|
||||
)
|
||||
def test_memberships_filters(
|
||||
self,
|
||||
authenticated_client,
|
||||
tenants_fixture,
|
||||
filter_name,
|
||||
filter_value,
|
||||
expected_count,
|
||||
):
|
||||
user_id = authenticated_client.user.pk
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
{f"filter[{filter_name}]": filter_value},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == expected_count
|
||||
|
||||
def test_memberships_filters_relationships(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
user_id = authenticated_client.user.pk
|
||||
tenant, *_ = tenants_fixture
|
||||
# No filter
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
# Filter by tenant
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
{"filter[tenant]": tenant.id},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name",
|
||||
[
|
||||
"role", # Valid filter, invalid value
|
||||
"tenant", # Valid filter, invalid value
|
||||
"invalid", # Invalid filter
|
||||
],
|
||||
)
|
||||
def test_memberships_filters_invalid(
|
||||
self, authenticated_client, tenants_fixture, filter_name
|
||||
):
|
||||
user_id = authenticated_client.user.pk
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
{f"filter[{filter_name}]": "whatever"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sort_field",
|
||||
[
|
||||
"tenant",
|
||||
"role",
|
||||
"date_joined",
|
||||
],
|
||||
)
|
||||
def test_memberships_sort(self, authenticated_client, tenants_fixture, sort_field):
|
||||
user_id = authenticated_client.user.pk
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
{"sort": sort_field},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_memberships_sort_invalid(self, authenticated_client, tenants_fixture):
|
||||
user_id = authenticated_client.user.pk
|
||||
response = authenticated_client.get(
|
||||
reverse("user-membership-list", kwargs={"user_pk": user_id}),
|
||||
{"sort": "invalid"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderViewSet:
|
||||
|
||||
@@ -9,6 +9,7 @@ from rest_framework_json_api.serializers import ValidationError
|
||||
from api.models import (
|
||||
StateChoices,
|
||||
User,
|
||||
Membership,
|
||||
Provider,
|
||||
Scan,
|
||||
Task,
|
||||
@@ -62,14 +63,11 @@ class UserSerializer(BaseSerializerV1):
|
||||
Serializer for the User model.
|
||||
"""
|
||||
|
||||
memberships = serializers.ResourceRelatedField(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"date_joined",
|
||||
]
|
||||
fields = ["id", "name", "username", "email", "date_joined", "memberships"]
|
||||
|
||||
|
||||
class UserCreateSerializer(BaseWriteSerializer):
|
||||
@@ -77,7 +75,7 @@ class UserCreateSerializer(BaseWriteSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["username", "password", "email"]
|
||||
fields = ["name", "username", "password", "email"]
|
||||
|
||||
def validate_password(self, value):
|
||||
user = User(**{k: v for k, v in self.initial_data.items() if k != "type"})
|
||||
@@ -99,7 +97,7 @@ class UserUpdateSerializer(BaseWriteSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "password", "email"]
|
||||
fields = ["id", "name", "password", "email"]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
}
|
||||
@@ -229,9 +227,32 @@ class TenantSerializer(BaseSerializerV1):
|
||||
Serializer for the Tenant model.
|
||||
"""
|
||||
|
||||
memberships = serializers.ResourceRelatedField(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = "__all__"
|
||||
fields = ["id", "name", "memberships"]
|
||||
|
||||
|
||||
# Memberships
|
||||
|
||||
|
||||
class MemberRoleEnumSerializerField(serializers.ChoiceField):
|
||||
def __init__(self, **kwargs):
|
||||
kwargs["choices"] = Membership.RoleChoices.choices
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class MembershipSerializer(serializers.ModelSerializer):
|
||||
role = MemberRoleEnumSerializerField()
|
||||
user = serializers.HyperlinkedRelatedField(view_name="user-detail", read_only=True)
|
||||
tenant = serializers.HyperlinkedRelatedField(
|
||||
view_name="tenant-detail", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["id", "user", "tenant", "role", "date_joined"]
|
||||
|
||||
|
||||
# Providers
|
||||
@@ -402,8 +423,8 @@ class ResourceSerializer(RLSSerializer):
|
||||
"type_",
|
||||
"tags",
|
||||
"provider",
|
||||
"url",
|
||||
"findings",
|
||||
"url",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularRedocView
|
||||
from rest_framework import routers
|
||||
from rest_framework_nested import routers
|
||||
|
||||
from api.v1.views import (
|
||||
SchemaView,
|
||||
UserViewSet,
|
||||
TenantViewSet,
|
||||
TenantMembersViewSet,
|
||||
MembershipViewSet,
|
||||
ProviderViewSet,
|
||||
ScanViewSet,
|
||||
TaskViewSet,
|
||||
@@ -23,9 +25,18 @@ router.register(r"tasks", TaskViewSet, basename="task")
|
||||
router.register(r"resources", ResourceViewSet, basename="resource")
|
||||
router.register(r"findings", FindingViewSet, basename="finding")
|
||||
|
||||
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
|
||||
tenants_router.register(
|
||||
r"memberships", TenantMembersViewSet, basename="tenant-membership"
|
||||
)
|
||||
|
||||
users_router = routers.NestedSimpleRouter(router, r"users", lookup="user")
|
||||
users_router.register(r"memberships", MembershipViewSet, basename="user-membership")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("", include(tenants_router.urls)),
|
||||
path("", include(users_router.urls)),
|
||||
path("schema", SchemaView.as_view(), name="schema"),
|
||||
path("docs", SpectacularRedocView.as_view(url_name="schema"), name="docs"),
|
||||
]
|
||||
+139
-8
@@ -1,36 +1,41 @@
|
||||
from celery.result import AsyncResult
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.postgres.search import SearchQuery
|
||||
from django.db.models import F
|
||||
from django.db.models import Q
|
||||
from django.db.models import F, Q
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiParameter,
|
||||
OpenApiTypes,
|
||||
)
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import MethodNotAllowed, NotFound
|
||||
from rest_framework.exceptions import MethodNotAllowed, NotFound, PermissionDenied
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework_json_api.views import Response
|
||||
|
||||
from api.base_views import BaseRLSViewSet, BaseViewSet
|
||||
from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseViewSet
|
||||
from api.filters import (
|
||||
ProviderFilter,
|
||||
TenantFilter,
|
||||
MembershipFilter,
|
||||
ScanFilter,
|
||||
TaskFilter,
|
||||
ResourceFilter,
|
||||
FindingFilter,
|
||||
)
|
||||
from api.models import User, Provider, Scan, Task, Resource, Finding
|
||||
from api.models import User, Membership, Provider, Scan, Task, Resource, Finding
|
||||
from api.rls import Tenant
|
||||
from api.uuid_utils import datetime_to_uuid7
|
||||
from api.v1.serializers import (
|
||||
UserSerializer,
|
||||
UserCreateSerializer,
|
||||
UserUpdateSerializer,
|
||||
MembershipSerializer,
|
||||
ProviderSerializer,
|
||||
ProviderCreateSerializer,
|
||||
ProviderUpdateSerializer,
|
||||
@@ -65,6 +70,10 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
retrieve=extend_schema(
|
||||
summary="Retrieve a user's information",
|
||||
description="Fetch detailed information about an authenticated user. It only allows using your own user ID.",
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Register a new user",
|
||||
description="Create a new user account by providing the necessary registration details.",
|
||||
@@ -111,9 +120,10 @@ class UserViewSet(BaseViewSet):
|
||||
def list(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="GET")
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="GET")
|
||||
if kwargs["pk"] != str(request.user.id):
|
||||
raise NotFound(detail="User was not found.")
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="me")
|
||||
def me(self, request):
|
||||
@@ -167,7 +177,7 @@ class UserViewSet(BaseViewSet):
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class TenantViewSet(BaseViewSet):
|
||||
class TenantViewSet(BaseTenantViewset):
|
||||
queryset = Tenant.objects.all()
|
||||
serializer_class = TenantSerializer
|
||||
http_method_names = ["get", "post", "patch", "delete"]
|
||||
@@ -179,6 +189,127 @@ class TenantViewSet(BaseViewSet):
|
||||
def get_queryset(self):
|
||||
return Tenant.objects.all()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
tenant = serializer.save()
|
||||
Membership.objects.create(
|
||||
user=self.request.user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
return Response(data=serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["User"],
|
||||
summary="List user memberships",
|
||||
description="Retrieve a list of all user memberships with options for filtering by various criteria.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["User"],
|
||||
summary="Retrieve membership data from the user",
|
||||
description="Fetch detailed information about a specific user membership by their ID.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
class MembershipViewSet(BaseTenantViewset):
|
||||
http_method_names = ["get"]
|
||||
serializer_class = MembershipSerializer
|
||||
queryset = Membership.objects.all()
|
||||
filterset_class = MembershipFilter
|
||||
ordering = ["date_joined"]
|
||||
ordering_fields = [
|
||||
"tenant",
|
||||
"role",
|
||||
"date_joined",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return Membership.objects.filter(user_id=user.id)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List tenant memberships",
|
||||
description="List the membership details of users in a tenant you are a part of.",
|
||||
tags=["Tenant"],
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="tenant_pk",
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
description="Tenant ID",
|
||||
),
|
||||
],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete tenant memberships",
|
||||
description="Delete the membership details of users in a tenant. You need to be one of the owners to delete a "
|
||||
"membership that is not yours. If you are the last owner of a tenant, you cannot delete your own "
|
||||
"membership.",
|
||||
tags=["Tenant"],
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
class TenantMembersViewSet(BaseTenantViewset):
|
||||
http_method_names = ["get", "delete"]
|
||||
serializer_class = MembershipSerializer
|
||||
queryset = Membership.objects.none()
|
||||
|
||||
def get_queryset(self):
|
||||
tenant = self.get_tenant()
|
||||
requesting_membership = self.get_requesting_membership(tenant)
|
||||
|
||||
if requesting_membership.role == Membership.RoleChoices.OWNER:
|
||||
return Membership.objects.filter(tenant=tenant)
|
||||
else:
|
||||
return Membership.objects.filter(tenant=tenant, user=self.request.user)
|
||||
|
||||
def get_tenant(self):
|
||||
tenant_id = self.kwargs.get("tenant_pk")
|
||||
tenant = get_object_or_404(Tenant, id=tenant_id)
|
||||
return tenant
|
||||
|
||||
def get_requesting_membership(self, tenant):
|
||||
try:
|
||||
membership = Membership.objects.get(user=self.request.user, tenant=tenant)
|
||||
except Membership.DoesNotExist:
|
||||
raise NotFound("Membership does not exist.")
|
||||
return membership
|
||||
|
||||
# TODO: Add invite functionality
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="GET")
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
tenant = self.get_tenant()
|
||||
membership_to_delete = get_object_or_404(
|
||||
Membership, tenant=tenant, id=kwargs.get("pk")
|
||||
)
|
||||
requesting_membership = self.get_requesting_membership(tenant)
|
||||
|
||||
if requesting_membership.role == Membership.RoleChoices.OWNER:
|
||||
if membership_to_delete.user == request.user:
|
||||
# Check if the user is the last owner
|
||||
other_owners = Membership.objects.filter(
|
||||
tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
).exclude(user=request.user)
|
||||
if not other_owners.exists():
|
||||
raise PermissionDenied(
|
||||
"You cannot delete your own membership as the last owner."
|
||||
)
|
||||
else:
|
||||
if membership_to_delete.user != request.user:
|
||||
raise PermissionDenied(
|
||||
"You do not have permission to delete this membership."
|
||||
)
|
||||
|
||||
membership_to_delete.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
|
||||
+24
-9
@@ -1,25 +1,26 @@
|
||||
import logging
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.db import connections as django_connections, connection as django_connection
|
||||
from django_celery_results.models import TaskResult
|
||||
from prowler.lib.outputs.finding import Severity, Status
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
from prowler.lib.outputs.finding import Severity, Status
|
||||
|
||||
from api.models import (
|
||||
Finding,
|
||||
)
|
||||
from api.models import (
|
||||
User,
|
||||
Provider,
|
||||
Finding,
|
||||
Resource,
|
||||
ResourceTag,
|
||||
Scan,
|
||||
StateChoices,
|
||||
Task,
|
||||
Membership,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
|
||||
@@ -70,13 +71,17 @@ def disable_logging():
|
||||
def create_test_user(django_db_setup, django_db_blocker):
|
||||
with django_db_blocker.unblock():
|
||||
user = User.objects.create_user(
|
||||
username=TEST_USER, password=TEST_PASSWORD, email="testing@gmail.com"
|
||||
name="testing",
|
||||
username=TEST_USER,
|
||||
password=TEST_PASSWORD,
|
||||
email="testing@gmail.com",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(create_test_user, client):
|
||||
client.user = create_test_user
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}"
|
||||
return client
|
||||
|
||||
@@ -89,13 +94,23 @@ def authenticated_api_client(create_test_user):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenants_fixture():
|
||||
def tenants_fixture(create_test_user):
|
||||
user = create_test_user
|
||||
tenant1 = Tenant.objects.create(
|
||||
name="Tenant One",
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=user,
|
||||
tenant=tenant1,
|
||||
)
|
||||
tenant2 = Tenant.objects.create(
|
||||
name="Tenant Two",
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=user,
|
||||
tenant=tenant2,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
return tenant1, tenant2
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user