Skip to content

Commit

Permalink
Merge pull request #802 from cisagov/AL-add-user-logs
Browse files Browse the repository at this point in the history
Add User Event Logs Functionality
  • Loading branch information
rapidray12 authored Feb 20, 2025
2 parents 5660899 + cb50ba9 commit 4aee2da
Show file tree
Hide file tree
Showing 11 changed files with 664 additions and 9 deletions.
1 change: 1 addition & 0 deletions backend/.pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ disable=C0103, # Conforms to snake_case
R0901, # Too many ancestors
R0902, # Too many instance attributes
R0903, # Too few public methods
R0911, # Too many return statements
R0914, # Too many local variables
R0917, # Too many positional arguments
R1705, # Unnecessary else
Expand Down
13 changes: 9 additions & 4 deletions backend/src/xfd_django/xfd_api/api_methods/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ..models import Organization, OrganizationTag, Role, Scan, ScanTask, User
from ..schema_models import organization_schema
from ..tasks.es_client import ESClient
from ..tools.serializers import serialize_role


def is_valid_uuid(val: str) -> bool:
Expand Down Expand Up @@ -858,15 +859,19 @@ def remove_role(organization_id: str, role_id, current_user):

try:
# Attempt to delete the role within the organization
result = Role.objects.filter(
organization_id=organization_id, id=role_id
).delete()
role = Role.objects.get(organization_id=organization_id, id=role_id)

result = role.delete()

# If no role was deleted, raise a 404
if result[0] == 0:
raise HTTPException(status_code=404, detail="Role not found")

return {"status": "success", "message": "Role removed successfully"}
return {
"status": "success",
"message": "Role removed successfully",
"roleDeleted": serialize_role(role),
}

except HTTPException as http_exc:
raise http_exc
Expand Down
2 changes: 2 additions & 0 deletions backend/src/xfd_django/xfd_api/api_methods/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from ..helpers.regionStateMap import REGION_STATE_MAP
from ..models import Organization, Role, User
from ..tools.serializers import serialize_user


def is_valid_uuid(val: str) -> bool:
Expand Down Expand Up @@ -169,6 +170,7 @@ def delete_user(target_user_id, current_user):
return {
"status": "success",
"message": "User {} has been deleted successfully.".format(target_user_id),
"userDeleted": serialize_user(target_user),
}

except HTTPException as http_exc:
Expand Down
144 changes: 144 additions & 0 deletions backend/src/xfd_django/xfd_api/api_methods/user_log_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""User log search."""

# Standard Python Libraries
from datetime import datetime
import json
import re
import traceback
from typing import Any, Dict

# Third-Party Libraries
from django.db.models import Q
from fastapi import HTTPException

from ..auth import is_global_view_admin
from ..models import Log


def parse_query_string(query):
"""
Parse a query string into a dictionary for JSONField filtering.
Example Input: "user.id:12345 user.name:John Doe"
Output: {"user__id": "12345", "user__name": "John Doe"}
"""
result = {}
# Match key:value pairs, allowing values with spaces
pattern = re.compile(r'(\w+(\.\w+)*)\s*:\s*("[^"]+"|\'[^\']+\'|\S+)')
matches = pattern.findall(query)

for match in matches:
key, _, value = match
# Remove quotes if present
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
# Replace dots with double underscores for Django ORM
orm_key = key.replace(".", "__")
result[orm_key] = value
return result


def generate_date_condition(filter_obj: Dict[str, Any]) -> Q:
"""Generate date condition."""
operator = filter_obj.get("operator")
value = filter_obj.get("value", "")

try:
date_obj = datetime.fromisoformat(value)
except ValueError:
raise ValueError("Invalid date format. Use ISO format.")

if operator == "is":
return Q(createdAt__exact=date_obj)
elif operator == "not":
return ~Q(createdAt__exact=date_obj)
elif operator == "after":
return Q(createdAt__gt=date_obj)
elif operator == "onOrAfter":
return Q(createdAt__gte=date_obj)
elif operator == "before":
return Q(createdAt__lt=date_obj)
elif operator == "onOrBefore":
return Q(createdAt__lte=date_obj)
elif operator == "empty":
return Q(createdAt__isnull=True)
elif operator == "notEmpty":
return Q(createdAt__isnull=False)
else:
raise ValueError("Invalid date operator.")


def generate_filter_qs(search: Dict[str, Any]) -> Q:
"""Generate a Q object based on the search filters."""
q = Q()
if "eventType" in search and search["eventType"]:
event_filter = search["eventType"]
q &= Q(eventType__icontains=event_filter["value"])

if "result" in search and search["result"]:
result_filter = search["result"]
q &= Q(result__icontains=result_filter["value"])

if "timestamp" in search and search["timestamp"]:
timestamp_filter = search["timestamp"]
# Use the correct field name "createdAt" instead of "created_at"
q &= generate_date_condition(timestamp_filter)

if "payload" in search and search["payload"]:
payload_filters = parse_query_string(search["payload"])
for key, value in payload_filters.items():
# This assumes your keys in the payload match your search keys.
q &= Q(**{f"payload__{key}": value})

return q


# POST: /log/search
def search_logs(search_data, current_user):
"""Search logs based on filters."""
try:
# Check if the user is a GlobalViewAdmin
if not is_global_view_admin(current_user):
raise HTTPException(status_code=403, detail="Unauthorized access.")

# Convert Pydantic model to dict and remove None values
search_dict = search_data.dict(exclude_unset=True)

# Generate Q object for filters
q_object = generate_filter_qs(search_dict)

# As Django ORM is synchronous, use sync_to_async
logs_qs = Log.objects.filter(q_object)

# Get count
count = logs_qs.count()

# Serialize logs
logs_serialized = []
for log in logs_qs:
try:
payload_dict = json.loads(log.payload)
except (ValueError, TypeError):
# If somehow it's not valid JSON, just keep it as a string
payload_dict = log.payload

logs_serialized.append(
{
"id": str(log.id),
"eventType": log.eventType,
"result": log.result,
"payload": payload_dict,
"createdAt": log.createdAt.isoformat(),
}
)

return logs_serialized, count

except ValueError as ve:
raise HTTPException(status_code=500, detail=str(ve))
except Exception as e:
print(e)
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
8 changes: 5 additions & 3 deletions backend/src/xfd_django/xfd_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,11 @@ class Log(models.Model):
"""The Log model."""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
payload = models.JSONField()
createdAt = models.DateTimeField(auto_now_add=True)
eventType = models.CharField(max_length=255, null=True, blank=True)
payload = models.TextField()
createdAt = models.DateTimeField(db_column="createdAt", auto_now_add=True)
eventType = models.CharField(
db_column="eventType", max_length=255, null=True, blank=True
)
result = models.CharField(max_length=255)

class Meta:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,21 @@ class GenericMessageResponseModel(BaseModel):
message: str


class RemoveRoleResponseModel(BaseModel):
"""Remove role response model."""

status: str
message: str


class DeleteUserResponseModel(BaseModel):
"""Delete user response model."""

status: str
message: str
userDeleted: Any


class OrganizationSearchBody(BaseModel):
"""Elastic search orgnaization model."""

Expand Down
78 changes: 78 additions & 0 deletions backend/src/xfd_django/xfd_api/schema_models/user_log_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""User event log schema."""

# Standard Python Libraries
from typing import Any, List, Optional

# Third-Party Libraries
from pydantic import BaseModel, validator


class Filter(BaseModel):
"""Filter schema."""

value: str
operator: Optional[str] = "contains"

@validator("operator")
def validate_operator(cls, v):
"""Validate operator."""
allowed = [
"contains",
"exact",
"iexact",
"startswith",
"istartswith",
"endswith",
"iendswith",
]
if v and v not in allowed:
raise ValueError(f"Operator must be one of {allowed}")
return v


class DateFilter(BaseModel):
"""Date filter schema."""

value: str
operator: str

@validator("operator")
def validate_operator(cls, v):
"""Validate operator."""
allowed = [
"is",
"not",
"after",
"onOrAfter",
"before",
"onOrBefore",
"empty",
"notEmpty",
]
if v not in allowed:
raise ValueError(f"Operator must be one of {allowed}")
return v


class LogSearch(BaseModel):
"""Log search schema."""

eventType: Optional[Filter] = None
result: Optional[Filter] = None
timestamp: Optional[DateFilter] = None
payload: Optional[str] = None

@validator("payload")
def validate_payload(cls, v):
"""Validate payload."""
if v:
if not isinstance(v, str):
raise ValueError("Payload must be a string")
return v


class LogSearchResponse(BaseModel):
"""Log search response model."""

result: List[Any]
count: int
Loading

0 comments on commit 4aee2da

Please sign in to comment.