mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat(Resources): add the /resources endpoint (#34)
* fix(Resources): add basic Resource MVC * fix(Resources): implement many-to-many tags via through table * fix(Resources): tsvector & migration migration needs work, need to create a custom django migration field to build the GENERATED ALWAYS column. Without it, django will complain about the ts column not exisiting when we try to create an index. * test(Resources): add tests for /resources views * fix(Resources): get basic test cases to pass need to work on tsvector column and filtering by provider columns like alias, name * fix(Resources): provide way to get tags as dict not a list of ResourceTag models * fix(Resources): annotate with return type * fix(Resources): rename search field to not conflict with filters * fix(Resources): filter by provider.id * fix(Resources): remove filter and sort by provider must use provider_id first * fix(Resources): test adding, clearing tags * fix(Scans): filter by provider_id * fix(Resources): remove FIXME comments * fix(Resources): filter and search on tags * fix(Resources): full text search * fix(Resources): full text search to include both resources and tags and get rid of the distinct() query * fix(Resources): document text search a bit more * fix(Resources): remove TODO comments * fix(Resources): move Resource migrations into 0001_initital * fix(Config): revert to match main * fix(Resources): use Django's default query builder for full text search * Apply suggestions from code review Mostly formatting changes Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com> * fix(Resources): just use text search instead of guessing at allowed split characters for each provider * fix(Resources): remove unused tests these cases are covered in parameterized lists * fix(Resources): set a read-only RLS policy * fix(Resources): rename add_tags to be upsert_or_delete_tags * fix(Resources): include primary key on through table django query builder doesn't like tables without primary keys * fix(Resources): default xx_at filters to date instead of date time * docs(Resources): describe what a Resource is in API docs * chore(Resources): rename file to match others --------- Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
This commit is contained in:
@@ -125,6 +125,7 @@ celerybeat.pid
|
||||
|
||||
# Environments
|
||||
.env
|
||||
*.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import Q
|
||||
from django_filters.rest_framework import (
|
||||
FilterSet,
|
||||
BooleanFilter,
|
||||
@@ -8,7 +9,7 @@ from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.db_utils import ProviderEnumField
|
||||
from api.models import Provider, Scan, Task, StateChoices
|
||||
from api.models import Provider, Resource, ResourceTag, Scan, Task, StateChoices
|
||||
from api.rls import Tenant
|
||||
from api.v1.serializers import TaskBase
|
||||
|
||||
@@ -128,7 +129,7 @@ class ScanFilter(FilterSet):
|
||||
model = Scan
|
||||
fields = {
|
||||
"provider": ["exact"],
|
||||
"provider_id": ["exact"],
|
||||
"provider_id": ["exact", "in"],
|
||||
"name": ["exact", "icontains"],
|
||||
"started_at": ["gte", "lte"],
|
||||
"trigger": ["exact"],
|
||||
@@ -160,3 +161,56 @@ class TaskFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = []
|
||||
|
||||
|
||||
class ResourceTagFilter(FilterSet):
|
||||
class Meta:
|
||||
model = ResourceTag
|
||||
fields = {
|
||||
"key": ["exact", "icontains"],
|
||||
"value": ["exact", "icontains"],
|
||||
}
|
||||
search = ["text_search"]
|
||||
|
||||
|
||||
class ResourceFilter(FilterSet):
|
||||
provider = CharFilter(method="filter_provider")
|
||||
tag_key = CharFilter(method="filter_tag_key")
|
||||
tag_value = CharFilter(method="filter_tag_value")
|
||||
tag = CharFilter(method="filter_tag")
|
||||
tags = CharFilter(method="filter_tag")
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
|
||||
|
||||
def filter_provider(self, queryset, name, value):
|
||||
return enum_filter(
|
||||
queryset,
|
||||
value,
|
||||
enum_choices=Provider.ProviderChoices,
|
||||
lookup_field="provider__provider",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = {
|
||||
"provider_id": ["exact", "in"],
|
||||
"uid": ["exact", "icontains"],
|
||||
"name": ["exact", "icontains"],
|
||||
"region": ["exact", "icontains", "in"],
|
||||
"service": ["exact", "icontains", "in"],
|
||||
"type": ["exact", "icontains", "in"],
|
||||
"inserted_at": ["gte", "lte"],
|
||||
"updated_at": ["gte", "lte"],
|
||||
}
|
||||
|
||||
def filter_tag_key(self, queryset, name, value):
|
||||
return queryset.filter(Q(tags__key=value) | Q(tags__key__icontains=value))
|
||||
|
||||
def filter_tag_value(self, queryset, name, value):
|
||||
return queryset.filter(Q(tags__value=value) | Q(tags__value__icontains=value))
|
||||
|
||||
def filter_tag(self, queryset, name, value):
|
||||
# we won't know what the user wants to filter on just based on the value
|
||||
# and we don't want to build special filtering logic for every possible
|
||||
# provider tag spec, so we'll just do a full text search
|
||||
return queryset.filter(tags__text_search=value)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
[
|
||||
{
|
||||
"model": "api.resource",
|
||||
"pk": "a3ba9470-a240-49a6-8196-9230a267a220",
|
||||
"fields": {
|
||||
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
|
||||
"provider": "37b065f8-26b0-4218-a665-0b23d07b27d9",
|
||||
"uid": "unique-1",
|
||||
"name": "testing 1",
|
||||
"inserted_at": "2024-08-01T17:20:27.050Z",
|
||||
"updated_at": "2024-08-01T17:20:27.050Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.resource",
|
||||
"pk": "85f18c25-4deb-460e-87e2-12548f2508ed",
|
||||
"fields": {
|
||||
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
|
||||
"provider": "37b065f8-26b0-4218-a665-0b23d07b27d9",
|
||||
"uid": "unique-2",
|
||||
"name": "testing 2",
|
||||
"inserted_at": "2024-08-01T17:20:27.050Z",
|
||||
"updated_at": "2024-08-01T17:20:27.050Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.resourcetag",
|
||||
"pk": "057c38c5-94aa-46ee-98bb-9ec5b0886bbf",
|
||||
"fields": {
|
||||
"tenant": "12646005-9067-4d2a-a098-8bb378604362",
|
||||
"key": "key",
|
||||
"value": "tag value",
|
||||
"inserted_at": "2024-08-01T17:20:27.050Z",
|
||||
"updated_at": "2024-08-01T17:20:27.050Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "api.resourcetagmapping",
|
||||
"fields": {
|
||||
"tag": "057c38c5-94aa-46ee-98bb-9ec5b0886bbf",
|
||||
"resource": "85f18c25-4deb-460e-87e2-12548f2508ed",
|
||||
"tenant": "12646005-9067-4d2a-a098-8bb378604362"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -346,4 +346,270 @@ class Migration(migrations.Migration):
|
||||
GRANT SELECT ON TABLE {TASK_RUNNER_DB_TABLE} TO {DB_PROWLER_USER};
|
||||
"""
|
||||
),
|
||||
# Resources
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP EXTENSION IF EXISTS pg_trgm;
|
||||
""",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Resource",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"uid",
|
||||
models.TextField(
|
||||
verbose_name="Unique identifier for the resource, set by the provider"
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.TextField(
|
||||
verbose_name="Name of the resource, as set in the provider"
|
||||
),
|
||||
),
|
||||
(
|
||||
"region",
|
||||
models.TextField(
|
||||
verbose_name="Location of the resource, as set by the provider"
|
||||
),
|
||||
),
|
||||
(
|
||||
"service",
|
||||
models.TextField(
|
||||
verbose_name="Service of the resource, as set by the provider"
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.TextField(
|
||||
verbose_name="Type of the resource, as set by the provider"
|
||||
),
|
||||
),
|
||||
(
|
||||
"text_search",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.contrib.postgres.search.CombinedSearchVector(
|
||||
django.contrib.postgres.search.CombinedSearchVector(
|
||||
django.contrib.postgres.search.CombinedSearchVector(
|
||||
django.contrib.postgres.search.SearchVector(
|
||||
"uid", config="simple", weight="A"
|
||||
),
|
||||
"||",
|
||||
django.contrib.postgres.search.SearchVector(
|
||||
"name", config="simple", weight="B"
|
||||
),
|
||||
django.contrib.postgres.search.SearchConfig(
|
||||
"simple"
|
||||
),
|
||||
),
|
||||
"||",
|
||||
django.contrib.postgres.search.SearchVector(
|
||||
"region", config="simple", weight="C"
|
||||
),
|
||||
django.contrib.postgres.search.SearchConfig("simple"),
|
||||
),
|
||||
"||",
|
||||
django.contrib.postgres.search.SearchVector(
|
||||
"service", "type", config="simple", weight="D"
|
||||
),
|
||||
django.contrib.postgres.search.SearchConfig("simple"),
|
||||
),
|
||||
null=True,
|
||||
output_field=django.contrib.postgres.search.SearchVectorField(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="resources",
|
||||
related_query_name="resource",
|
||||
to="api.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "resources",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ResourceTag",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("key", models.TextField()),
|
||||
("value", models.TextField()),
|
||||
(
|
||||
"text_search",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.contrib.postgres.search.CombinedSearchVector(
|
||||
django.contrib.postgres.search.SearchVector(
|
||||
"key", config="simple", weight="A"
|
||||
),
|
||||
"||",
|
||||
django.contrib.postgres.search.SearchVector(
|
||||
"value", config="simple", weight="B"
|
||||
),
|
||||
django.contrib.postgres.search.SearchConfig("simple"),
|
||||
),
|
||||
null=True,
|
||||
output_field=django.contrib.postgres.search.SearchVectorField(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "resource_tags",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ResourceTagMapping",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"resource",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="api.resource",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tag",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="api.resourcetag",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="api.tenant",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "resource_tag_mappings",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="resource",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(
|
||||
through="api.ResourceTagMapping",
|
||||
to="api.resourcetag",
|
||||
verbose_name="Tags associated with the resource, by provider",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="resourcetag",
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
fields=["text_search"], name="gin_resource_tags_search_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="resource",
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
fields=["text_search"], name="gin_resources_search_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="resourcetag",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id", "key", "value"),
|
||||
name="unique_resource_tags_by_tenant_key_value",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="resourcetag",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_resourcetag",
|
||||
statements=["SELECT"],
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="resourcetagmapping",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id", "resource_id", "tag_id"),
|
||||
name="unique_resource_tag_mappings_by_tenant_resource_tag",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="resourcetagmapping",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_resourcetagmapping",
|
||||
statements=["SELECT"],
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="resource",
|
||||
index=models.Index(
|
||||
fields=["uid", "region", "service", "name"],
|
||||
name="idx_resource_uid_reg_serv_name",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="resource",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant_id", "provider_id", "uid"),
|
||||
name="unique_resources_by_provider",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="resource",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_resource",
|
||||
statements=["SELECT"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
+155
-1
@@ -1,6 +1,8 @@
|
||||
import re
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -155,7 +157,7 @@ class Scan(RowLevelSecurityProtectedModel):
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["provider", "state", "trigger", "scheduled_at"],
|
||||
name="scans_prov_state_type_sche_idx",
|
||||
name="scans_prov_state_trig_sche_idx",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -189,3 +191,155 @@ class Task(RowLevelSecurityProtectedModel):
|
||||
name="tasks_id_trt_id_idx",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ResourceTag(RowLevelSecurityProtectedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
key = models.TextField(blank=False)
|
||||
value = models.TextField(blank=False)
|
||||
|
||||
text_search = models.GeneratedField(
|
||||
expression=SearchVector("key", weight="A", config="simple")
|
||||
+ SearchVector("value", weight="B", config="simple"),
|
||||
output_field=SearchVectorField(),
|
||||
db_persist=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "resource_tags"
|
||||
|
||||
indexes = [
|
||||
GinIndex(fields=["text_search"], name="gin_resource_tags_search_idx"),
|
||||
]
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "key", "value"),
|
||||
name="unique_resource_tags_by_tenant_key_value",
|
||||
),
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Resource(RowLevelSecurityProtectedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
|
||||
provider = models.ForeignKey(
|
||||
Provider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="resources",
|
||||
related_query_name="resource",
|
||||
)
|
||||
|
||||
uid = models.TextField(
|
||||
"Unique identifier for the resource, set by the provider", blank=False
|
||||
)
|
||||
name = models.TextField("Name of the resource, as set in the provider", blank=False)
|
||||
region = models.TextField(
|
||||
"Location of the resource, as set by the provider", blank=False
|
||||
)
|
||||
service = models.TextField(
|
||||
"Service of the resource, as set by the provider", blank=False
|
||||
)
|
||||
type = models.TextField("Type of the resource, as set by the provider", blank=False)
|
||||
|
||||
text_search = models.GeneratedField(
|
||||
expression=SearchVector("uid", weight="A", config="simple")
|
||||
+ SearchVector("name", weight="B", config="simple")
|
||||
+ SearchVector("region", weight="C", config="simple")
|
||||
+ SearchVector("service", "type", weight="D", config="simple"),
|
||||
output_field=SearchVectorField(),
|
||||
db_persist=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
tags = models.ManyToManyField(
|
||||
ResourceTag,
|
||||
verbose_name="Tags associated with the resource, by provider",
|
||||
through="ResourceTagMapping",
|
||||
)
|
||||
|
||||
def get_tags(self) -> dict:
|
||||
return {tag.key: tag.value for tag in self.tags.all()}
|
||||
|
||||
def clear_tags(self):
|
||||
self.tags.clear()
|
||||
self.save()
|
||||
|
||||
def upsert_or_delete_tags(self, tags: list[ResourceTag] | None):
|
||||
if tags is None:
|
||||
self.clear_tags()
|
||||
return
|
||||
|
||||
# Add new relationships with the tenant_id field
|
||||
for tag in tags:
|
||||
ResourceTagMapping.objects.update_or_create(
|
||||
tag=tag, resource=self, tenant_id=self.tenant_id
|
||||
)
|
||||
|
||||
# Save the instance
|
||||
self.save()
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "resources"
|
||||
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["uid", "region", "service", "name"],
|
||||
name="idx_resource_uid_reg_serv_name",
|
||||
),
|
||||
GinIndex(fields=["text_search"], name="gin_resources_search_idx"),
|
||||
]
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "provider_id", "uid"),
|
||||
name="unique_resources_by_provider",
|
||||
),
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ResourceTagMapping(RowLevelSecurityProtectedModel):
|
||||
# NOTE that we don't really need a primary key here,
|
||||
# but everything is easier with django if we do
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
resource = models.ForeignKey(Resource, on_delete=models.DO_NOTHING)
|
||||
tag = models.ForeignKey(ResourceTag, on_delete=models.CASCADE)
|
||||
|
||||
class Meta(RowLevelSecurityProtectedModel.Meta):
|
||||
db_table = "resource_tag_mappings"
|
||||
|
||||
# django will automatically create indexes for:
|
||||
# - resource_id
|
||||
# - tag_id
|
||||
# - tenant_id
|
||||
# - id
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("tenant_id", "resource_id", "tag_id"),
|
||||
name="unique_resource_tag_mappings_by_tenant_resource_tag",
|
||||
),
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT"],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import pytest
|
||||
|
||||
from api.models import Resource, ResourceTag
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestResourceModel:
|
||||
def test_setting_tags(self, providers_fixture):
|
||||
provider, *_ = providers_fixture
|
||||
|
||||
resource = Resource.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
provider=provider,
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
|
||||
name="My Instance 1",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
type="prowler-test",
|
||||
)
|
||||
|
||||
tags = [
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
key="key",
|
||||
value="value",
|
||||
),
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
key="key2",
|
||||
value="value2",
|
||||
),
|
||||
]
|
||||
|
||||
resource.upsert_or_delete_tags(tags)
|
||||
|
||||
assert len(tags) == len(resource.tags.all())
|
||||
|
||||
tags_dict = resource.get_tags()
|
||||
|
||||
for tag in tags:
|
||||
assert tag.key in tags_dict
|
||||
assert tag.value == tags_dict[tag.key]
|
||||
|
||||
def test_adding_tags(self, resources_fixture):
|
||||
resource, *_ = resources_fixture
|
||||
|
||||
tags = [
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=resource.tenant_id,
|
||||
key="env",
|
||||
value="test",
|
||||
),
|
||||
]
|
||||
before_count = len(resource.tags.all())
|
||||
|
||||
resource.upsert_or_delete_tags(tags)
|
||||
|
||||
assert before_count + 1 == len(resource.tags.all())
|
||||
|
||||
tags_dict = resource.get_tags()
|
||||
|
||||
assert "env" in tags_dict
|
||||
assert tags_dict["env"] == "test"
|
||||
|
||||
def test_adding_duplicate_tags(self, resources_fixture):
|
||||
resource, *_ = resources_fixture
|
||||
|
||||
tags = resource.tags.all()
|
||||
|
||||
before_count = len(resource.tags.all())
|
||||
|
||||
resource.upsert_or_delete_tags(tags)
|
||||
|
||||
# should be the same number of tags
|
||||
assert before_count == len(resource.tags.all())
|
||||
|
||||
def test_add_tags_none(self, resources_fixture):
|
||||
resource, *_ = resources_fixture
|
||||
resource.upsert_or_delete_tags(None)
|
||||
|
||||
assert len(resource.tags.all()) == 0
|
||||
assert resource.get_tags() == {}
|
||||
|
||||
def test_clear_tags(self, resources_fixture):
|
||||
resource, *_ = resources_fixture
|
||||
resource.clear_tags()
|
||||
|
||||
assert len(resource.tags.all()) == 0
|
||||
assert resource.get_tags() == {}
|
||||
@@ -776,6 +776,31 @@ class TestScanViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
def test_scan_filter_by_provider_id_exact(
|
||||
self, client, scans_fixture, tenant_header
|
||||
):
|
||||
response = client.get(
|
||||
reverse("scan-list"),
|
||||
{"filter[provider_id]": scans_fixture[0].provider.id},
|
||||
headers=tenant_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
def test_scan_filter_by_provider_id_in(self, client, scans_fixture, tenant_header):
|
||||
response = client.get(
|
||||
reverse("scan-list"),
|
||||
{
|
||||
"filter[provider_id.in]": [
|
||||
scans_fixture[0].provider.id,
|
||||
scans_fixture[1].provider.id,
|
||||
]
|
||||
},
|
||||
headers=tenant_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sort_field",
|
||||
[
|
||||
@@ -849,3 +874,156 @@ class TestTaskViewSet:
|
||||
)
|
||||
# Task status is SUCCESS
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestResourceViewSet:
|
||||
def test_resources_list_none(self, client, tenant_header):
|
||||
response = client.get(reverse("resource-list"), headers=tenant_header)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 0
|
||||
|
||||
def test_resources_list(self, client, resources_fixture, tenant_header):
|
||||
response = client.get(reverse("resource-list"), headers=tenant_header)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(resources_fixture)
|
||||
assert (
|
||||
response.json()["data"][0]["attributes"]["uid"] == resources_fixture[0].uid
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name, filter_value, expected_count",
|
||||
(
|
||||
[
|
||||
(
|
||||
"uid",
|
||||
"arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
|
||||
1,
|
||||
),
|
||||
("uid.icontains", "i-1234567890abcdef", 3),
|
||||
("name", "My Instance 2", 1),
|
||||
("name.icontains", "ce 2", 1),
|
||||
("region", "eu-west-1", 1),
|
||||
("region.icontains", "west", 1),
|
||||
("service", "ec2", 2),
|
||||
("service.icontains", "ec", 2),
|
||||
("inserted_at.gte", "2024-01-01 00:00:00", 3),
|
||||
("updated_at.lte", "2024-01-01 00:00:00", 0),
|
||||
("type.icontains", "prowler", 2),
|
||||
# tags searching
|
||||
("tag", "key3:value:value", 0),
|
||||
("tag_key", "key3", 1),
|
||||
("tag_value", "value2", 2),
|
||||
("tag", "key3:multi word value3", 1),
|
||||
("tags", "key3:multi word value3", 1),
|
||||
("tags", "multi word", 1),
|
||||
# full text search on resource
|
||||
("search", "arn", 3),
|
||||
("search", "def1", 1),
|
||||
# full text search on resource tags
|
||||
("search", "multi word", 1),
|
||||
("search", "key2", 2),
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_resource_filters(
|
||||
self,
|
||||
client,
|
||||
resources_fixture,
|
||||
tenant_header,
|
||||
filter_name,
|
||||
filter_value,
|
||||
expected_count,
|
||||
):
|
||||
response = client.get(
|
||||
reverse("resource-list"),
|
||||
{f"filter[{filter_name}]": filter_value},
|
||||
headers=tenant_header,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == expected_count
|
||||
|
||||
def test_resource_filter_by_provider_id_in(
|
||||
self, client, resources_fixture, tenant_header
|
||||
):
|
||||
response = client.get(
|
||||
reverse("resource-list"),
|
||||
{
|
||||
"filter[provider_id.in]": [
|
||||
resources_fixture[0].provider.id,
|
||||
resources_fixture[1].provider.id,
|
||||
]
|
||||
},
|
||||
headers=tenant_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 2
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_name",
|
||||
(
|
||||
[
|
||||
"resource", # Invalid filter name
|
||||
"invalid",
|
||||
]
|
||||
),
|
||||
)
|
||||
def test_resources_filters_invalid(self, client, tenant_header, filter_name):
|
||||
response = client.get(
|
||||
reverse("resource-list"),
|
||||
{f"filter[{filter_name}]": "whatever"},
|
||||
headers=tenant_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sort_field",
|
||||
[
|
||||
"provider_id",
|
||||
"uid",
|
||||
"name",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
],
|
||||
)
|
||||
def test_resources_sort(self, client, tenant_header, sort_field):
|
||||
response = client.get(
|
||||
reverse("resource-list"), {"sort": sort_field}, headers=tenant_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_resources_sort_invalid(self, client, tenant_header):
|
||||
response = client.get(
|
||||
reverse("resource-list"), {"sort": "invalid"}, headers=tenant_header
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["errors"][0]["code"] == "invalid"
|
||||
assert response.json()["errors"][0]["source"]["pointer"] == "/data"
|
||||
assert (
|
||||
response.json()["errors"][0]["detail"] == "invalid sort parameter: invalid"
|
||||
)
|
||||
|
||||
def test_resources_retrieve(self, client, resources_fixture, tenant_header):
|
||||
resource_1, *_ = resources_fixture
|
||||
response = client.get(
|
||||
reverse("resource-detail", kwargs={"pk": resource_1.id}),
|
||||
headers=tenant_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["uid"] == resource_1.uid
|
||||
assert response.json()["data"]["attributes"]["name"] == resource_1.name
|
||||
assert response.json()["data"]["attributes"]["region"] == resource_1.region
|
||||
assert response.json()["data"]["attributes"]["service"] == resource_1.service
|
||||
assert response.json()["data"]["attributes"]["type"] == resource_1.type
|
||||
assert response.json()["data"]["attributes"]["tags"] == resource_1.get_tags()
|
||||
|
||||
def test_resources_invalid_retrieve(self, client, tenant_header):
|
||||
response = client.get(
|
||||
reverse("resource-detail", kwargs={"pk": "random_id"}),
|
||||
headers=tenant_header,
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -4,7 +4,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework_json_api import serializers
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.models import StateChoices, Provider, Scan, Task
|
||||
from api.models import StateChoices, Provider, Scan, Task, Resource, ResourceTag
|
||||
from api.rls import Tenant
|
||||
from api.utils import merge_dicts
|
||||
|
||||
@@ -276,3 +276,59 @@ class ScanUpdateSerializer(BaseWriteSerializer):
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class ResourceTagSerializer(RLSSerializer):
|
||||
"""
|
||||
Serializer fore the ResourceTag model
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = ResourceTag
|
||||
fields = ["key", "value"]
|
||||
|
||||
|
||||
class ResourceSerializer(RLSSerializer):
|
||||
"""
|
||||
Serializer for the Resource model.
|
||||
"""
|
||||
|
||||
tags = serializers.SerializerMethodField()
|
||||
type_ = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = [
|
||||
"id",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
"uid",
|
||||
"name",
|
||||
"region",
|
||||
"service",
|
||||
"type_",
|
||||
"tags",
|
||||
"provider",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"inserted_at": {"read_only": True},
|
||||
"updated_at": {"read_only": True},
|
||||
}
|
||||
|
||||
@extend_schema_field(
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Tags associated with the resource",
|
||||
"example": {"env": "prod", "owner": "johndoe"},
|
||||
}
|
||||
)
|
||||
def get_tags(self, obj):
|
||||
return obj.get_tags()
|
||||
|
||||
def get_fields(self):
|
||||
"""`type` is a Python reserved keyword."""
|
||||
fields = super().get_fields()
|
||||
type_ = fields.pop("type_")
|
||||
fields["type"] = type_
|
||||
return fields
|
||||
|
||||
@@ -8,6 +8,7 @@ from api.v1.views import (
|
||||
ProviderViewSet,
|
||||
ScanViewSet,
|
||||
TaskViewSet,
|
||||
ResourceViewSet,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter(trailing_slash=False)
|
||||
@@ -16,6 +17,7 @@ router.register(r"tenants", TenantViewSet, basename="tenant")
|
||||
router.register(r"providers", ProviderViewSet, basename="provider")
|
||||
router.register(r"scans", ScanViewSet, basename="scan")
|
||||
router.register(r"tasks", TaskViewSet, basename="task")
|
||||
router.register(r"resources", ResourceViewSet, basename="resource")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
|
||||
@@ -3,6 +3,9 @@ from django.db.models import F
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.contrib.postgres.search import SearchQuery
|
||||
from django.db.models import Q
|
||||
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
@@ -13,8 +16,14 @@ from rest_framework_json_api.views import Response
|
||||
from celery.result import AsyncResult
|
||||
|
||||
from api.base_views import BaseRLSViewSet, BaseViewSet
|
||||
from api.filters import ProviderFilter, TenantFilter, ScanFilter, TaskFilter
|
||||
from api.models import Provider, Scan, Task
|
||||
from api.filters import (
|
||||
ProviderFilter,
|
||||
TenantFilter,
|
||||
ScanFilter,
|
||||
TaskFilter,
|
||||
ResourceFilter,
|
||||
)
|
||||
from api.models import Provider, Scan, Task, Resource
|
||||
from api.rls import Tenant
|
||||
from api.v1.serializers import (
|
||||
ProviderSerializer,
|
||||
@@ -26,6 +35,7 @@ from api.v1.serializers import (
|
||||
ScanSerializer,
|
||||
ScanCreateSerializer,
|
||||
ScanUpdateSerializer,
|
||||
ResourceSerializer,
|
||||
)
|
||||
from tasks.tasks import check_provider_connection_task, delete_provider_task
|
||||
|
||||
@@ -331,3 +341,62 @@ class TaskViewSet(BaseRLSViewSet):
|
||||
"Content-Location": reverse("task-detail", kwargs={"pk": task.id})
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List all resources",
|
||||
description="Retrieve a list of all resources with options for filtering by various criteria. Resources are objects that are discovered by Prowler. They can be anything from a single host to a whole VPC.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Retrieve data for a resource",
|
||||
description="Fetch detailed information about a specific resource by their ID. A Resource is an object that is discovered by Prowler. It can be anything from a single host to a whole VPC.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class ResourceViewSet(BaseRLSViewSet):
|
||||
queryset = Resource.objects.all()
|
||||
serializer_class = ResourceSerializer
|
||||
http_method_names = ["get"]
|
||||
filterset_class = ResourceFilter
|
||||
ordering = ["inserted_at"]
|
||||
ordering_fields = [
|
||||
"provider_id",
|
||||
"uid",
|
||||
"name",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Resource.objects.all()
|
||||
search_value = self.request.query_params.get("filter[search]", None)
|
||||
|
||||
if search_value:
|
||||
# Django's ORM will build a LEFT JOIN and OUTER JOIN on the "through" table, resulting in duplicates
|
||||
# The duplicates then require a `distinct` query
|
||||
search_query = SearchQuery(
|
||||
search_value, config="simple", search_type="plain"
|
||||
)
|
||||
queryset = queryset.filter(
|
||||
Q(tags__key=search_value)
|
||||
| Q(tags__value=search_value)
|
||||
| Q(tags__text_search=search_query)
|
||||
| Q(tags__key__contains=search_value)
|
||||
| Q(tags__value__contains=search_value)
|
||||
| Q(uid=search_value)
|
||||
| Q(name=search_value)
|
||||
| Q(region=search_value)
|
||||
| Q(service=search_value)
|
||||
| Q(text_search=search_query)
|
||||
| Q(uid__contains=search_value)
|
||||
| Q(name__contains=search_value)
|
||||
| Q(region__contains=search_value)
|
||||
| Q(service__contains=search_value)
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -15,6 +15,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
"api",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
|
||||
+65
-3
@@ -5,7 +5,7 @@ from django.conf import settings
|
||||
from django.db import connections as django_connections
|
||||
from rest_framework import status
|
||||
from django_celery_results.models import TaskResult
|
||||
from api.models import Provider, Scan, StateChoices, Task
|
||||
from api.models import Provider, Resource, ResourceTag, Scan, StateChoices, Task
|
||||
from api.rls import Tenant
|
||||
|
||||
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
|
||||
@@ -95,7 +95,7 @@ def providers_fixture(tenants_fixture):
|
||||
@pytest.fixture
|
||||
def scans_fixture(tenants_fixture, providers_fixture):
|
||||
tenant, _ = tenants_fixture
|
||||
provider, *_ = providers_fixture
|
||||
provider, provider2, *_ = providers_fixture
|
||||
|
||||
scan1 = Scan.objects.create(
|
||||
name="Scan 1",
|
||||
@@ -115,7 +115,7 @@ def scans_fixture(tenants_fixture, providers_fixture):
|
||||
)
|
||||
scan3 = Scan.objects.create(
|
||||
name="Scan 3",
|
||||
provider=provider,
|
||||
provider=provider2,
|
||||
trigger=Scan.TriggerChoices.SCHEDULED,
|
||||
state=StateChoices.AVAILABLE,
|
||||
tenant_id=tenant.id,
|
||||
@@ -152,6 +152,68 @@ def tasks_fixture(tenants_fixture):
|
||||
return task1, task2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resources_fixture(providers_fixture):
|
||||
provider, *_ = providers_fixture
|
||||
|
||||
tags = [
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
key="key",
|
||||
value="value",
|
||||
),
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
key="key2",
|
||||
value="value2",
|
||||
),
|
||||
]
|
||||
|
||||
resource1 = Resource.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
provider=provider,
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
|
||||
name="My Instance 1",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
type="prowler-test",
|
||||
)
|
||||
|
||||
resource1.upsert_or_delete_tags(tags)
|
||||
|
||||
resource2 = Resource.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
provider=provider,
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef1",
|
||||
name="My Instance 2",
|
||||
region="eu-west-1",
|
||||
service="ec2",
|
||||
type="prowler-test",
|
||||
)
|
||||
resource2.upsert_or_delete_tags(tags)
|
||||
|
||||
resource3 = Resource.objects.create(
|
||||
tenant_id=providers_fixture[1].tenant_id,
|
||||
provider=providers_fixture[1],
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:bucket/i-1234567890abcdef2",
|
||||
name="My Bucket 3",
|
||||
region="us-east-1",
|
||||
service="s3",
|
||||
type="test",
|
||||
)
|
||||
|
||||
tags = [
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
key="key3",
|
||||
value="multi word value3",
|
||||
),
|
||||
]
|
||||
resource3.upsert_or_delete_tags(tags)
|
||||
|
||||
return resource1, resource2, resource3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_header(tenants_fixture):
|
||||
return {"X-Tenant-ID": str(tenants_fixture[0].id)}
|
||||
|
||||
Reference in New Issue
Block a user