From 4c83351b26ed5f56e6d0db65b19d116b8a2164ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Wed, 25 Sep 2024 13:12:15 +0200 Subject: [PATCH] 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 --- poetry.lock | 19 +- pyproject.toml | 1 + src/backend/api/base_views.py | 13 + src/backend/api/db_utils.py | 13 + src/backend/api/filters.py | 22 + src/backend/api/fixtures/0_dev_users.json | 12 + src/backend/api/fixtures/1_dev_tenants.json | 30 ++ .../{4_dev_scans.json => 3_dev_scans.json} | 0 ...ev_resources.json => 4_dev_resources.json} | 0 src/backend/api/migrations/0001_initial.py | 169 +++++++- src/backend/api/models.py | 45 ++- src/backend/api/rls.py | 1 - src/backend/api/specs/v1.yaml | 375 +++++++++++++++++- .../tests/integration/test_authentication.py | 1 + src/backend/api/tests/test_views.py | 306 +++++++++++++- src/backend/api/v1/serializers.py | 41 +- src/backend/api/v1/urls.py | 13 +- src/backend/api/v1/views.py | 147 ++++++- src/backend/conftest.py | 33 +- 19 files changed, 1195 insertions(+), 46 deletions(-) rename src/backend/api/fixtures/{4_dev_scans.json => 3_dev_scans.json} (100%) rename src/backend/api/fixtures/{3_dev_resources.json => 4_dev_resources.json} (100%) diff --git a/poetry.lock b/poetry.lock index 3cb0204841..61082a2705 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand. [[package]] name = "about-time" @@ -1562,6 +1562,21 @@ files = [ [package.dependencies] djangorestframework = ">=3.9.3" +[[package]] +name = "drf-nested-routers" +version = "0.94.1" +description = "Nested resources for the Django Rest Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "drf-nested-routers-0.94.1.tar.gz", hash = "sha256:2b846385ed95c9f17bf4242db3b264ac826b5af00dda6c737d3fe7cc7bf2c7db"}, + {file = "drf_nested_routers-0.94.1-py2.py3-none-any.whl", hash = "sha256:3a8ec45a025c0f39188ec1ec415244beb875a6f4db87911a1f5a606d09b68c9f"}, +] + +[package.dependencies] +Django = ">=4.2" +djangorestframework = ">=3.14.0" + [[package]] name = "drf-spectacular" version = "0.27.2" @@ -4815,4 +4830,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.13" -content-hash = "b92ed16fc637dd09de05de5d76460cc2b47c6d93855b4fecbaaf64f3d9fcfdcf" +content-hash = "3ba98c48a9f65ae923bbc99af5fbbcd98f24972bba5dd519f24e03f4b6f0b30d" diff --git a/pyproject.toml b/pyproject.toml index b7cdd3f4f6..74fcbf8e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ django-postgres-extra = "^2.0.8" djangorestframework = "3.15.2" djangorestframework-jsonapi = "7.0.2" djangorestframework-simplejwt = "^5.3.1" +drf-nested-routers = "^0.94.1" drf-spectacular = "0.27.2" drf-spectacular-jsonapi = "0.4.1" gunicorn = "23.0.0" diff --git a/src/backend/api/base_views.py b/src/backend/api/base_views.py index 77397145d4..4bb1c0d25b 100644 --- a/src/backend/api/base_views.py +++ b/src/backend/api/base_views.py @@ -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) diff --git a/src/backend/api/db_utils.py b/src/backend/api/db_utils.py index 935f2ffc65..636601a21e 100644 --- a/src/backend/api/db_utils.py +++ b/src/backend/api/db_utils.py @@ -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 diff --git a/src/backend/api/filters.py b/src/backend/api/filters.py index ccc7e51726..43b215e13b 100644 --- a/src/backend/api/filters.py +++ b/src/backend/api/filters.py @@ -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") diff --git a/src/backend/api/fixtures/0_dev_users.json b/src/backend/api/fixtures/0_dev_users.json index b7bfa2802c..b902ff4623 100644 --- a/src/backend/api/fixtures/0_dev_users.json +++ b/src/backend/api/fixtures/0_dev_users.json @@ -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" + } } ] diff --git a/src/backend/api/fixtures/1_dev_tenants.json b/src/backend/api/fixtures/1_dev_tenants.json index 42bcf9034f..6003a4b8ef 100644 --- a/src/backend/api/fixtures/1_dev_tenants.json +++ b/src/backend/api/fixtures/1_dev_tenants.json @@ -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" + } } ] diff --git a/src/backend/api/fixtures/4_dev_scans.json b/src/backend/api/fixtures/3_dev_scans.json similarity index 100% rename from src/backend/api/fixtures/4_dev_scans.json rename to src/backend/api/fixtures/3_dev_scans.json diff --git a/src/backend/api/fixtures/3_dev_resources.json b/src/backend/api/fixtures/4_dev_resources.json similarity index 100% rename from src/backend/api/fixtures/3_dev_resources.json rename to src/backend/api/fixtures/4_dev_resources.json diff --git a/src/backend/api/migrations/0001_initial.py b/src/backend/api/migrations/0001_initial.py index b6e76c78d6..d68ce8052a 100644 --- a/src/backend/api/migrations/0001_initial.py +++ b/src/backend/api/migrations/0001_initial.py @@ -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, diff --git a/src/backend/api/models.py b/src/backend/api/models.py index e4eb4f0dc3..5c8244cb63 100644 --- a/src/backend/api/models.py +++ b/src/backend/api/models.py @@ -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") diff --git a/src/backend/api/rls.py b/src/backend/api/rls.py index 0f6e4a2400..bfa9bbb18f 100644 --- a/src/backend/api/rls.py +++ b/src/backend/api/rls.py @@ -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. diff --git a/src/backend/api/specs/v1.yaml b/src/backend/api/specs/v1.yaml index 846dd8da6a..c7467f5ebc 100644 --- a/src/backend/api/specs/v1.yaml +++ b/src/backend/api/specs/v1.yaml @@ -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 diff --git a/src/backend/api/tests/integration/test_authentication.py b/src/backend/api/tests/integration/test_authentication.py index 29aa4b260b..df95f2258a 100644 --- a/src/backend/api/tests/integration/test_authentication.py +++ b/src/backend/api/tests/integration/test_authentication.py @@ -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", diff --git a/src/backend/api/tests/test_views.py b/src/backend/api/tests/test_views.py index 08c5795ff5..e0d096602f 100644 --- a/src/backend/api/tests/test_views.py +++ b/src/backend/api/tests/test_views.py @@ -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: diff --git a/src/backend/api/v1/serializers.py b/src/backend/api/v1/serializers.py index 493f0092fc..7858b6abbd 100644 --- a/src/backend/api/v1/serializers.py +++ b/src/backend/api/v1/serializers.py @@ -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}, diff --git a/src/backend/api/v1/urls.py b/src/backend/api/v1/urls.py index 3e0332b69d..685f9064c8 100644 --- a/src/backend/api/v1/urls.py +++ b/src/backend/api/v1/urls.py @@ -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"), ] diff --git a/src/backend/api/v1/views.py b/src/backend/api/v1/views.py index 78a639d758..e29a63194e 100644 --- a/src/backend/api/v1/views.py +++ b/src/backend/api/v1/views.py @@ -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( diff --git a/src/backend/conftest.py b/src/backend/conftest.py index 02e679e925..43e4d91e53 100644 --- a/src/backend/conftest.py +++ b/src/backend/conftest.py @@ -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