Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions backend/api/utils/access/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ def user_can_access_app(user_id, app_id):
OrganisationMember = apps.get_model("api", "OrganisationMember")
App = apps.get_model("api", "App")

app = App.objects.get(id=app_id)
org_member = OrganisationMember.objects.get(
user_id=user_id, organisation=app.organisation, deleted_at=None
)
try:
app = App.objects.get(id=app_id)
org_member = OrganisationMember.objects.get(
user_id=user_id, organisation=app.organisation, deleted_at=None
)
except (App.DoesNotExist, OrganisationMember.DoesNotExist):
return False
return org_member in app.members.all()


Expand All @@ -36,10 +39,13 @@ def user_can_access_environment(user_id, env_id):
Environment = apps.get_model("api", "Environment")
EnvironmentKey = apps.get_model("api", "EnvironmentKey")

env = Environment.objects.get(id=env_id)
org_member = OrganisationMember.objects.get(
organisation=env.app.organisation, user_id=user_id, deleted_at=None
)
try:
env = Environment.objects.get(id=env_id)
org_member = OrganisationMember.objects.get(
organisation=env.app.organisation, user_id=user_id, deleted_at=None
)
except (Environment.DoesNotExist, OrganisationMember.DoesNotExist):
return False
return EnvironmentKey.objects.filter(
user_id=org_member, environment_id=env_id
).exists()
Expand All @@ -50,10 +56,13 @@ def service_account_can_access_environment(account_id, env_id):
EnvironmentKey = apps.get_model("api", "EnvironmentKey")
ServiceAccount = apps.get_model("api", "ServiceAccount")

env = Environment.objects.get(id=env_id)
service_account = ServiceAccount.objects.get(
organisation=env.app.organisation, id=account_id, deleted_at=None
)
try:
env = Environment.objects.get(id=env_id)
service_account = ServiceAccount.objects.get(
organisation=env.app.organisation, id=account_id, deleted_at=None
)
except (Environment.DoesNotExist, ServiceAccount.DoesNotExist):
return False
return EnvironmentKey.objects.filter(
service_account=service_account, environment_id=env_id
).exists()
Expand Down
52 changes: 51 additions & 1 deletion backend/api/utils/rest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from api.models import EnvironmentToken, ServiceAccountToken, ServiceToken, UserToken
from django.utils import timezone
from django.utils.html import strip_tags
from django.core.validators import validate_email
from django.core.exceptions import ValidationError as DjangoValidationError
import base64
from api.utils.access.ip import get_client_ip

Expand All @@ -20,7 +23,10 @@ def get_resolver_request_meta(request):


def get_token_type(auth_token):
return auth_token.split(" ")[1]
parts = auth_token.split(" ")
if len(parts) < 2 or not parts[1]:
return None
return parts[1]


def get_env_from_service_token(auth_token):
Expand Down Expand Up @@ -101,6 +107,50 @@ def token_is_expired_or_deleted(auth_token):
)


def validate_text_field(value, field_name, max_length=None, required=True):
"""Validate and sanitize a text field from request data.

Returns (cleaned_value, error_message).
If error_message is not None, the caller should return a 400 response.
"""
if value is None or (isinstance(value, str) and not value.strip()):
if required:
return None, f"Missing required field: {field_name}"
return None, None

if not isinstance(value, str):
return None, f"'{field_name}' must be a string."

cleaned = strip_tags(value).strip()
if required and not cleaned:
return None, f"Missing required field: {field_name}"

if max_length and len(cleaned) > max_length:
return None, f"'{field_name}' cannot exceed {max_length} characters."

return cleaned, None


def validate_email_address(email):
"""Validate an email address using Django's built-in validator.

Returns (cleaned_email, error_message).
"""
if not email or not isinstance(email, str):
return None, "Missing required field: email"

cleaned = email.strip().lower()
if not cleaned:
return None, "Missing required field: email"

try:
validate_email(cleaned)
except DjangoValidationError:
return None, f"'{cleaned}' is not a valid email address."

return cleaned, None


def encode_string_to_base64(s):
# Convert string to bytes
byte_representation = s.encode("utf-8")
Expand Down
65 changes: 23 additions & 42 deletions backend/api/views/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
)
from api.utils.audit_logging import log_audit_event, get_actor_info, build_change_values
from api.utils.environments import create_environment
from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta
from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta, validate_text_field
from api.throttling import PlanBasedRateThrottle
from api.utils.access.middleware import IsIPAllowed
from backend.quotas import can_add_app, can_use_custom_envs

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.response import Response
from rest_framework import status
from djangorestframework_camel_case.render import CamelCaseJSONRenderer
Expand Down Expand Up @@ -53,7 +53,7 @@ def initial(self, request, *args, **kwargs):

action = METHOD_TO_ACTION.get(request.method)
if not action:
raise PermissionDenied(f"Unsupported HTTP method: {request.method}")
raise MethodNotAllowed(request.method)

account = None
is_sa = False
Expand Down Expand Up @@ -102,25 +102,15 @@ def post(self, request, *args, **kwargs):
org = self._get_org(request)

# --- Validate input ---
name = request.data.get("name")
if not name or not str(name).strip():
return Response(
{"error": "Missing required field: name"},
status=status.HTTP_400_BAD_REQUEST,
)
name = str(name).strip()
if len(name) > 64:
return Response(
{"error": "App name cannot exceed 64 characters."},
status=status.HTTP_400_BAD_REQUEST,
)
name, err = validate_text_field(request.data.get("name"), "name", max_length=64)
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)

description = request.data.get("description", None)
if description is not None and len(str(description)) > 10000:
return Response(
{"error": "App description cannot exceed 10,000 characters."},
status=status.HTTP_400_BAD_REQUEST,
)
if description is not None:
description, err = validate_text_field(description, "description", max_length=10000, required=False)
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)

# --- Validate optional environments list ---
custom_envs = request.data.get("environments", None)
Expand Down Expand Up @@ -289,7 +279,7 @@ def initial(self, request, *args, **kwargs):

action = METHOD_TO_ACTION.get(request.method)
if not action:
raise PermissionDenied(f"Unsupported HTTP method: {request.method}")
raise MethodNotAllowed(request.method)

account = None
is_sa = False
Expand Down Expand Up @@ -321,10 +311,10 @@ def get(self, request, app_id, *args, **kwargs):
def put(self, request, app_id, *args, **kwargs):
app = request.auth["app"]

name = request.data.get("name")
description = request.data.get("description")
raw_name = request.data.get("name")
raw_desc = request.data.get("description")

if name is None and description is None:
if raw_name is None and raw_desc is None:
return Response(
{"error": "At least one of 'name' or 'description' must be provided."},
status=status.HTTP_400_BAD_REQUEST,
Expand All @@ -334,25 +324,16 @@ def put(self, request, app_id, *args, **kwargs):
app, ["name", "description"], request.data
)

if name is not None:
if not name or str(name).strip() == "":
return Response(
{"error": "App name cannot be blank."},
status=status.HTTP_400_BAD_REQUEST,
)
if len(str(name)) > 64:
return Response(
{"error": "App name cannot exceed 64 characters."},
status=status.HTTP_400_BAD_REQUEST,
)
app.name = str(name).strip()
if raw_name is not None:
name, err = validate_text_field(raw_name, "name", max_length=64)
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)
app.name = name

if description is not None:
if len(str(description)) > 10000:
return Response(
{"error": "App description cannot exceed 10,000 characters."},
status=status.HTTP_400_BAD_REQUEST,
)
if raw_desc is not None:
description, err = validate_text_field(raw_desc, "description", max_length=10000, required=False)
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)
app.description = description

app.save()
Expand Down
6 changes: 3 additions & 3 deletions backend/api/views/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.response import Response
from rest_framework import status
from djangorestframework_camel_case.render import CamelCaseJSONRenderer
Expand All @@ -43,7 +43,7 @@ def initial(self, request, *args, **kwargs):

action = METHOD_TO_ACTION.get(request.method)
if not action:
raise PermissionDenied(f"Unsupported HTTP method: {request.method}")
raise MethodNotAllowed(request.method)

account = None
is_sa = False
Expand Down Expand Up @@ -176,7 +176,7 @@ def initial(self, request, *args, **kwargs):

action = METHOD_TO_ACTION.get(request.method)
if not action:
raise PermissionDenied(f"Unsupported HTTP method: {request.method}")
raise MethodNotAllowed(request.method)

account = None
is_sa = False
Expand Down
51 changes: 39 additions & 12 deletions backend/api/views/members.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.response import Response
from rest_framework import status
from djangorestframework_camel_case.render import CamelCaseJSONRenderer
Expand All @@ -32,7 +32,7 @@
from api.utils.audit_logging import log_audit_event, get_actor_info, get_member_display_name
from api.utils.crypto import decrypt_asymmetric, get_server_keypair
from api.utils.environments import _ed25519_pk_to_curve25519, _wrap_env_secrets_for_key
from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta
from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta, validate_email_address
from api.throttling import PlanBasedRateThrottle
from api.utils.access.middleware import IsIPAllowed
from backend.quotas import can_add_account
Expand Down Expand Up @@ -93,7 +93,7 @@ def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
action = METHOD_TO_ACTION.get(request.method)
if not action:
raise PermissionDenied(f"Unsupported HTTP method: {request.method}")
raise MethodNotAllowed(request.method)
_check_permission(request, action)

def get(self, request, *args, **kwargs):
Expand All @@ -109,14 +109,13 @@ def post(self, request, *args, **kwargs):
org = _get_org(request)
invited_by = request.auth.get("org_member") # None for SA tokens

email = request.data.get("email", "").strip().lower()
raw_email = request.data.get("email", "")
role_id = request.data.get("role_id")

if not email:
return Response(
{"error": "Missing required field: email"},
status=status.HTTP_400_BAD_REQUEST,
)
email, err = validate_email_address(raw_email)
if err:
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)

if not role_id:
return Response(
{"error": "Missing required field: role_id"},
Expand Down Expand Up @@ -237,7 +236,7 @@ def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
action = METHOD_TO_ACTION.get(request.method)
if not action:
raise PermissionDenied(f"Unsupported HTTP method: {request.method}")
raise MethodNotAllowed(request.method)
_check_permission(request, action)

def _get_member(self, member_id, org):
Expand All @@ -261,8 +260,15 @@ def put(self, request, member_id, *args, **kwargs):
if not member:
return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND)

# Prevent self role-update
# The Owner role is immutable via the API — use the ownership transfer flow
if member.role.is_default and member.role.name.lower() == "owner":
return Response(
{"error": "The Owner's role cannot be changed via the API. Use the ownership transfer flow."},
status=status.HTTP_403_FORBIDDEN,
)

if request.auth["auth_type"] == "User":
# Prevent self role-update
acting_member = request.auth["org_member"]
if str(acting_member.id) == str(member.id):
return Response(
Expand All @@ -277,6 +283,13 @@ def put(self, request, member_id, *args, **kwargs):
{"error": "You cannot update the role of a member with global access."},
status=status.HTTP_403_FORBIDDEN,
)
elif request.auth["auth_type"] == "ServiceAccount":
# SAs cannot modify members with global-access roles (e.g. Admin)
if role_has_global_access(member.role):
return Response(
{"error": "Service accounts cannot modify members with global access."},
status=status.HTTP_403_FORBIDDEN,
)

role_id = request.data.get("role_id")
if not role_id:
Expand Down Expand Up @@ -339,13 +352,27 @@ def delete(self, request, member_id, *args, **kwargs):
if not member:
return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND)

# Prevent self-removal
# The Owner cannot be removed via the API
if member.role.is_default and member.role.name.lower() == "owner":
return Response(
{"error": "The Owner cannot be removed via the API. Use the ownership transfer flow."},
status=status.HTTP_403_FORBIDDEN,
)

if request.auth["auth_type"] == "User":
# Prevent self-removal
if str(request.auth["org_member"].id) == str(member.id):
return Response(
{"error": "You cannot remove yourself from the organisation."},
status=status.HTTP_403_FORBIDDEN,
)
elif request.auth["auth_type"] == "ServiceAccount":
# SAs cannot remove members with global-access roles (e.g. Admin)
if role_has_global_access(member.role):
return Response(
{"error": "Service accounts cannot remove members with global access."},
status=status.HTTP_403_FORBIDDEN,
)

member_display_name = get_member_display_name(member)
member_email = member.user.email
Expand Down
Loading