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:
Víctor Fernández Poyatos authored and GitHub committed 2024-09-25 13:12:15 +02:00
1 parent 4a881fd2fd
commit 4c83351b26
19 files changed
+1195 -46

No files matched your search

+13
View File
@@ -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)
+13
View File
@@ -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
+22
View File
@@ -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")
+12
View File
@@ -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"
}
}
]
+162 -7
View File
@@ -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,
+43 -2
View File
@@ -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")
-1
View File
@@ -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.
+373 -2
View File
@@ -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",
+302 -4
View File
@@ -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:
+31 -10
View File
@@ -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},
+12 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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