Skip to content
Merged
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
4 changes: 3 additions & 1 deletion backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from datetime import datetime
import os
import logging
from api.utils.rest import encode_string_to_base64, get_client_ip
from api.utils.rest import encode_string_to_base64
from api.models import OrganisationMember
from django.utils import timezone
from smtplib import SMTPException

from api.utils.access.ip import get_client_ip

logger = logging.getLogger(__name__)


Expand Down
21 changes: 21 additions & 0 deletions backend/api/utils/access/ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from ipaddress import ip_address


def get_client_ip(request):
"""
Get the client IP address as a single string.

Args:
request: Django request object

Returns:
str | None: The client IP address (IPv4 or IPv6)
"""
raw_ip = (request.META.get("HTTP_X_REAL_IP") or "").strip()
if not raw_ip:
return None
try:
ip_address(raw_ip)
except ValueError:
return None
return raw_ip
6 changes: 2 additions & 4 deletions backend/api/utils/access/middleware.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# permissions.py

from api.models import NetworkAccessPolicy, Organisation
from api.utils.access.ip import get_client_ip
from rest_framework.permissions import BasePermission
from itertools import chain

Expand All @@ -15,10 +16,7 @@ class IsIPAllowed(BasePermission):
)

def get_client_ip(self, request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR")
return get_client_ip(request)

def has_permission(self, request, view):
ip = self.get_client_ip(request)
Expand Down
10 changes: 1 addition & 9 deletions backend/api/utils/rest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from api.models import EnvironmentToken, ServiceAccountToken, ServiceToken, UserToken
from django.utils import timezone
import base64
from api.utils.access.ip import get_client_ip

# Map HTTP methods to permission actions
METHOD_TO_ACTION = {
Expand All @@ -11,15 +12,6 @@
}


def get_client_ip(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0]
else:
ip = request.META.get("REMOTE_ADDR")
return ip


def get_resolver_request_meta(request):
user_agent = request.META.get("HTTP_USER_AGENT", "Unknown")
ip_address = get_client_ip(request)
Expand Down
10 changes: 9 additions & 1 deletion backend/api/views/graphql.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
from graphene_django.views import GraphQLView
from django.contrib.auth.mixins import LoginRequiredMixin
from graphql import specified_rules
from backend.graphene.validation import DuplicateFieldLimitRule, AliasUsageLimitRule


CUSTOM_RULES = tuple(specified_rules) + (
DuplicateFieldLimitRule,
AliasUsageLimitRule,
)


class PrivateGraphQLView(LoginRequiredMixin, GraphQLView):
raise_exception = True
pass
validation_rules = CUSTOM_RULES
6 changes: 2 additions & 4 deletions backend/api/views/kms.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from datetime import datetime

from api.utils.access.ip import get_client_ip
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from django.http import JsonResponse, HttpResponse
from api.utils.rest import (
get_client_ip,
)

from logs.models import KMSDBLog
from api.models import (
App,
Expand Down
8 changes: 3 additions & 5 deletions backend/backend/graphene/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from api.models import NetworkAccessPolicy, Organisation, OrganisationMember

from itertools import chain
from api.utils.access.ip import get_client_ip


class IPRestrictedError(GraphQLError):
Expand Down Expand Up @@ -51,7 +52,7 @@ def resolve(self, next, root, info: GraphQLResolveInfo, **kwargs):
except OrganisationMember.DoesNotExist:
raise GraphQLError("You are not a member of this organisation")

ip = self.get_client_ip(request)
ip = get_client_ip(request)

account_policies = org_member.network_policies.all()
global_policies = (
Expand All @@ -70,7 +71,4 @@ def resolve(self, next, root, info: GraphQLResolveInfo, **kwargs):
raise IPRestrictedError(org_member.organisation.name)

def get_client_ip(self, request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR")
return get_client_ip(request)
12 changes: 12 additions & 0 deletions backend/backend/graphene/mutations/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,9 @@ def mutate(cls, root, info, secrets_data):
"You don't have permission to create secrets in this organisation"
)

if not user_can_access_environment(info.context.user.userId, env.id):
raise GraphQLError("You don't have access to this environment")

tags = SecretTag.objects.filter(id__in=secret_data.tags)

path = (
Expand Down Expand Up @@ -846,6 +849,9 @@ def mutate(cls, root, info, id, secret_data):
"You don't have permission to update secrets in this organisation"
)

if not user_can_access_environment(info.context.user.userId, env.id):
raise GraphQLError("You don't have access to this environment")

tags = SecretTag.objects.filter(id__in=secret_data.tags)

path = (
Expand Down Expand Up @@ -905,6 +911,9 @@ def mutate(cls, root, info, secrets_data):
"You don't have permission to update secrets in this organisation"
)

if not user_can_access_environment(info.context.user.userId, env.id):
raise GraphQLError("You don't have access to this environment")

tags = SecretTag.objects.filter(id__in=secret_data.tags)

path = (
Expand Down Expand Up @@ -1003,6 +1012,9 @@ def mutate(cls, root, info, ids):
"You don't have permission to delete secrets in this organisation"
)

if not user_can_access_environment(info.context.user.userId, env.id):
raise GraphQLError("You don't have access to this environment")

secret.updated_at = timezone.now()
secret.deleted_at = timezone.now()
secret.save()
Expand Down
11 changes: 11 additions & 0 deletions backend/backend/graphene/mutations/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ def mutate(cls, root, info, org_id, invites):

app_scope = App.objects.filter(id__in=apps)

# Restrict roles that can be assigned via invites
allowed_invite_roles = ["developer", "service"]
role = Role.objects.get(organisation=org, id=role_id)
if role.name.lower() not in allowed_invite_roles:
raise GraphQLError(
f"You can only invite members with the following roles: {', '.join(allowed_invite_roles)}"
)

new_invite = OrganisationMemberInvite.objects.create(
organisation=org,
role_id=role_id,
Expand Down Expand Up @@ -317,6 +325,9 @@ def mutate(cls, root, info, member_id, role_id):
):
raise GraphQLError("You dont have permission to change member roles")

if org_member.user == info.context.user:
raise GraphQLError("You can't change your own role in an organisation")

active_user_role = OrganisationMember.objects.get(
user=info.context.user,
organisation=org_member.organisation,
Expand Down
22 changes: 19 additions & 3 deletions backend/backend/graphene/mutations/service_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
ServiceAccountToken,
Identity,
)
from api.utils.access.permissions import user_has_permission, user_is_org_member
from api.utils.access.permissions import (
role_has_global_access,
user_has_permission,
user_is_org_member,
)
from backend.graphene.types import ServiceAccountTokenType, ServiceAccountType
from datetime import datetime
from django.conf import settings
Expand Down Expand Up @@ -58,10 +62,17 @@ def mutate(
if handlers is None or len(handlers) == 0:
raise GraphQLError("At least one service account handler must be provided")

role = Role.objects.get(id=role_id, organisation=org)

if role_has_global_access(role):
raise GraphQLError(
f"Service Accounts cannot be assigned the '{role.name}' role."
)

service_account = ServiceAccount.objects.create(
name=name,
organisation=org,
role=Role.objects.get(id=role_id),
role=role,
identity_key=identity_key,
server_wrapped_keyring=server_wrapped_keyring,
server_wrapped_recovery=server_wrapped_recovery,
Expand Down Expand Up @@ -168,7 +179,12 @@ def mutate(cls, root, info, service_account_id, name, role_id, identity_ids=None
"You don't have the permissions required to update Service Accounts in this organisation"
)

role = Role.objects.get(id=role_id)
role = Role.objects.get(id=role_id, organisation=service_account.organisation)

if role_has_global_access(role):
raise GraphQLError(
f"Service Accounts cannot be assigned the '{role.name}' role."
)
service_account.name = name
service_account.role = role
if identity_ids is not None:
Expand Down
8 changes: 2 additions & 6 deletions backend/backend/graphene/queries/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Role,
Identity,
)
from api.utils.access.ip import get_client_ip
from graphql import GraphQLError
from django.db import transaction
from api.utils.access.roles import default_roles
Expand Down Expand Up @@ -96,12 +97,7 @@ def resolve_network_access_policies(root, info, organisation_id):

def resolve_client_ip(root, info):
request = info.context
# Use common headers to support reverse proxies
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
ip = x_forwarded_for.split(",")[0].strip()
else:
ip = request.META.get("REMOTE_ADDR")
ip = get_client_ip(request)
return ip


Expand Down
89 changes: 89 additions & 0 deletions backend/backend/graphene/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from collections import Counter
from graphql import GraphQLError
from graphql.language.ast import FieldNode
from graphql.validation import ValidationRule


class DuplicateFieldLimitRule(ValidationRule):
"""Limits how many times the same response field (name or alias) can appear
in a single selection set. Uses a Counter stack to track counts per nested
selection set and reports an error if any exceeds MAX_DUPLICATE_FIELDS."""

MAX_DUPLICATE_FIELDS = 20

def __init__(self, context):
super().__init__(context)
self.max_duplicates = self.MAX_DUPLICATE_FIELDS
self._stack = [] # Stack of Counters for nested selection sets

def enter_selection_set(self, *_):
"""Push a new Counter for a nested selection set."""
self._stack.append(Counter())

def leave_selection_set(self, node, *_):
"""Pop Counter, emit an error for any response name exceeding limit."""
counts = self._stack.pop()
for response_name, hits in counts.items():
if hits > self.max_duplicates:
offending = [
selection
for selection in node.selections
if isinstance(selection, FieldNode)
and (
selection.alias.value
if selection.alias
else selection.name.value
)
== response_name
]
self.context.report_error(
GraphQLError(
f"Field '{response_name}' requested {hits} times; limit is {self.max_duplicates}.",
nodes=offending or [node],
)
)

def enter_field(self, node, *_):
"""Increment count for this field’s response name (alias or original)."""
response_name = node.alias.value if node.alias else node.name.value
if not self._stack:
self._stack.append(Counter())
self._stack[-1][response_name] += 1


class AliasUsageLimitRule(ValidationRule):
"""Caps the number of aliases used within a single operation definition.
Tracks alias count per operation; reports an error when exceeding
MAX_ALIAS_FIELDS."""

MAX_ALIAS_FIELDS = 20

def __init__(self, context):
super().__init__(context)
self.max_aliases = self.MAX_ALIAS_FIELDS
self._operation_alias_counts = (
[]
) # Stack for nested operations (fragments not counted)

def enter_operation_definition(self, *_):
"""Start alias count for a new operation."""
self._operation_alias_counts.append(0)

def leave_operation_definition(self, *_):
"""End alias count scope for the operation."""
self._operation_alias_counts.pop()

def enter_field(self, node, *_):
"""Increment alias counter when a field has an alias; error if limit exceeded."""
if node.alias:
if not self._operation_alias_counts:
self._operation_alias_counts.append(0)
alias_count = self._operation_alias_counts[-1] + 1
self._operation_alias_counts[-1] = alias_count
if alias_count > self.max_aliases:
self.context.report_error(
GraphQLError(
f"Alias limit of {self.max_aliases} exceeded in a single operation.",
nodes=[node],
)
)
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ djangorestframework==3.15.2
djangorestframework-camel-case==1.4.2
freezegun==1.5.1
graphene==3.2.1
graphene-django==3.0.0
graphene-django==3.2.0
graphql-core==3.2.3
graphql-relay==3.2.0
gunicorn==23.0.0
Expand Down