Skip to content

Commit fe603d2

Browse files
feat: add misc graphql validation rules (#686)
* feat: implement custom validation rules for duplicate fields and alias usage for graphene Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * chore: misc cleanup and documentation, reduce limits Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * feat: add access control checks for environment in bulk secret mutations Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * fix: add validation for role changes, invite roles Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * fix: client IP discovery utils (#687) * refactor: use standardized get_client_ip util Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * refactor: simplify IP retrieval in IsIPAllowed permission class by using get_client_ip utility * refactor: streamline client IP retrieval in IPWhitelistMiddleware by utilizing get_client_ip utility * refactor: enhance get_client_ip utility to validate IP addresses and improve retrieval logic * debug: add print statement to log raw IP address in get_client_ip utility * chore: add a blank line at the end of ip.py for consistency * refactor: update client IP retrieval logic in get_client_ip utility to prioritize HTTP_X_REAL_IP * refactor: remove debug print statement from get_client_ip utility * fix: remove unnecessary fallbacks Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> --------- Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> Co-authored-by: rohan <rohan.chaturvedi@protonmail.com> * fix: add env access check for bulk secret deletes Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * fix: validate service account roles during create and update Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> * fix: add safe fallback to raw ip in case header is missing Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> --------- Signed-off-by: rohan <rohan.chaturvedi@protonmail.com> Co-authored-by: Nimish <85357445+nimish-ks@users.noreply.github.com>
1 parent 5f9f297 commit fe603d2

File tree

13 files changed

+175
-34
lines changed

13 files changed

+175
-34
lines changed

backend/api/emails.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from datetime import datetime
55
import os
66
import logging
7-
from api.utils.rest import encode_string_to_base64, get_client_ip
7+
from api.utils.rest import encode_string_to_base64
88
from api.models import OrganisationMember
99
from django.utils import timezone
1010
from smtplib import SMTPException
1111

12+
from api.utils.access.ip import get_client_ip
13+
1214
logger = logging.getLogger(__name__)
1315

1416

backend/api/utils/access/ip.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from ipaddress import ip_address
2+
3+
4+
def get_client_ip(request):
5+
"""
6+
Get the client IP address as a single string.
7+
8+
Args:
9+
request: Django request object
10+
11+
Returns:
12+
str | None: The client IP address (IPv4 or IPv6)
13+
"""
14+
raw_ip = (request.META.get("HTTP_X_REAL_IP") or "").strip()
15+
if not raw_ip:
16+
return None
17+
try:
18+
ip_address(raw_ip)
19+
except ValueError:
20+
return None
21+
return raw_ip

backend/api/utils/access/middleware.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# permissions.py
22

33
from api.models import NetworkAccessPolicy, Organisation
4+
from api.utils.access.ip import get_client_ip
45
from rest_framework.permissions import BasePermission
56
from itertools import chain
67

@@ -15,10 +16,7 @@ class IsIPAllowed(BasePermission):
1516
)
1617

1718
def get_client_ip(self, request):
18-
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
19-
if x_forwarded_for:
20-
return x_forwarded_for.split(",")[0].strip()
21-
return request.META.get("REMOTE_ADDR")
19+
return get_client_ip(request)
2220

2321
def has_permission(self, request, view):
2422
ip = self.get_client_ip(request)

backend/api/utils/rest.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from api.models import EnvironmentToken, ServiceAccountToken, ServiceToken, UserToken
22
from django.utils import timezone
33
import base64
4+
from api.utils.access.ip import get_client_ip
45

56
# Map HTTP methods to permission actions
67
METHOD_TO_ACTION = {
@@ -11,15 +12,6 @@
1112
}
1213

1314

14-
def get_client_ip(request):
15-
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
16-
if x_forwarded_for:
17-
ip = x_forwarded_for.split(",")[0]
18-
else:
19-
ip = request.META.get("REMOTE_ADDR")
20-
return ip
21-
22-
2315
def get_resolver_request_meta(request):
2416
user_agent = request.META.get("HTTP_USER_AGENT", "Unknown")
2517
ip_address = get_client_ip(request)

backend/api/views/graphql.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
from graphene_django.views import GraphQLView
22
from django.contrib.auth.mixins import LoginRequiredMixin
3+
from graphql import specified_rules
4+
from backend.graphene.validation import DuplicateFieldLimitRule, AliasUsageLimitRule
5+
6+
7+
CUSTOM_RULES = tuple(specified_rules) + (
8+
DuplicateFieldLimitRule,
9+
AliasUsageLimitRule,
10+
)
311

412

513
class PrivateGraphQLView(LoginRequiredMixin, GraphQLView):
614
raise_exception = True
7-
pass
15+
validation_rules = CUSTOM_RULES

backend/api/views/kms.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
from datetime import datetime
2-
2+
from api.utils.access.ip import get_client_ip
33
from rest_framework.decorators import api_view, permission_classes
44
from rest_framework.permissions import AllowAny
55
from django.http import JsonResponse, HttpResponse
6-
from api.utils.rest import (
7-
get_client_ip,
8-
)
6+
97
from logs.models import KMSDBLog
108
from api.models import (
119
App,

backend/backend/graphene/middleware.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from api.models import NetworkAccessPolicy, Organisation, OrganisationMember
44

55
from itertools import chain
6+
from api.utils.access.ip import get_client_ip
67

78

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

54-
ip = self.get_client_ip(request)
55+
ip = get_client_ip(request)
5556

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

7273
def get_client_ip(self, request):
73-
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
74-
if x_forwarded_for:
75-
return x_forwarded_for.split(",")[0].strip()
76-
return request.META.get("REMOTE_ADDR")
74+
return get_client_ip(request)

backend/backend/graphene/mutations/environment.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,9 @@ def mutate(cls, root, info, secrets_data):
784784
"You don't have permission to create secrets in this organisation"
785785
)
786786

787+
if not user_can_access_environment(info.context.user.userId, env.id):
788+
raise GraphQLError("You don't have access to this environment")
789+
787790
tags = SecretTag.objects.filter(id__in=secret_data.tags)
788791

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

852+
if not user_can_access_environment(info.context.user.userId, env.id):
853+
raise GraphQLError("You don't have access to this environment")
854+
849855
tags = SecretTag.objects.filter(id__in=secret_data.tags)
850856

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

914+
if not user_can_access_environment(info.context.user.userId, env.id):
915+
raise GraphQLError("You don't have access to this environment")
916+
908917
tags = SecretTag.objects.filter(id__in=secret_data.tags)
909918

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

1015+
if not user_can_access_environment(info.context.user.userId, env.id):
1016+
raise GraphQLError("You don't have access to this environment")
1017+
10061018
secret.updated_at = timezone.now()
10071019
secret.deleted_at = timezone.now()
10081020
secret.save()

backend/backend/graphene/mutations/organisation.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ def mutate(cls, root, info, org_id, invites):
161161

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

164+
# Restrict roles that can be assigned via invites
165+
allowed_invite_roles = ["developer", "service"]
166+
role = Role.objects.get(organisation=org, id=role_id)
167+
if role.name.lower() not in allowed_invite_roles:
168+
raise GraphQLError(
169+
f"You can only invite members with the following roles: {', '.join(allowed_invite_roles)}"
170+
)
171+
164172
new_invite = OrganisationMemberInvite.objects.create(
165173
organisation=org,
166174
role_id=role_id,
@@ -317,6 +325,9 @@ def mutate(cls, root, info, member_id, role_id):
317325
):
318326
raise GraphQLError("You dont have permission to change member roles")
319327

328+
if org_member.user == info.context.user:
329+
raise GraphQLError("You can't change your own role in an organisation")
330+
320331
active_user_role = OrganisationMember.objects.get(
321332
user=info.context.user,
322333
organisation=org_member.organisation,

backend/backend/graphene/mutations/service_accounts.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
ServiceAccountToken,
1010
Identity,
1111
)
12-
from api.utils.access.permissions import user_has_permission, user_is_org_member
12+
from api.utils.access.permissions import (
13+
role_has_global_access,
14+
user_has_permission,
15+
user_is_org_member,
16+
)
1317
from backend.graphene.types import ServiceAccountTokenType, ServiceAccountType
1418
from datetime import datetime
1519
from django.conf import settings
@@ -58,10 +62,17 @@ def mutate(
5862
if handlers is None or len(handlers) == 0:
5963
raise GraphQLError("At least one service account handler must be provided")
6064

65+
role = Role.objects.get(id=role_id, organisation=org)
66+
67+
if role_has_global_access(role):
68+
raise GraphQLError(
69+
f"Service Accounts cannot be assigned the '{role.name}' role."
70+
)
71+
6172
service_account = ServiceAccount.objects.create(
6273
name=name,
6374
organisation=org,
64-
role=Role.objects.get(id=role_id),
75+
role=role,
6576
identity_key=identity_key,
6677
server_wrapped_keyring=server_wrapped_keyring,
6778
server_wrapped_recovery=server_wrapped_recovery,
@@ -168,7 +179,12 @@ def mutate(cls, root, info, service_account_id, name, role_id, identity_ids=None
168179
"You don't have the permissions required to update Service Accounts in this organisation"
169180
)
170181

171-
role = Role.objects.get(id=role_id)
182+
role = Role.objects.get(id=role_id, organisation=service_account.organisation)
183+
184+
if role_has_global_access(role):
185+
raise GraphQLError(
186+
f"Service Accounts cannot be assigned the '{role.name}' role."
187+
)
172188
service_account.name = name
173189
service_account.role = role
174190
if identity_ids is not None:

0 commit comments

Comments
 (0)