Skip to content

Commit db68569

Browse files
authored
Merge branch 'main' into feat--secret-generation
2 parents da51a8d + e326e7e commit db68569

File tree

59 files changed

+3181
-385
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+3181
-385
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 4.2.16 on 2025-04-23 07:58
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import uuid
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('api', '0099_remove_secretfolder_unique_secret_folder_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='NetworkAccessPolicy',
17+
fields=[
18+
('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
19+
('name', models.CharField(max_length=100)),
20+
('allowed_ips', models.TextField(help_text='Comma-separated list of IP addresses or CIDR ranges (e.g. 192.168.1.1, 10.0.0.0/24)')),
21+
('is_global', models.BooleanField(default=True)),
22+
('created_at', models.DateTimeField(auto_now_add=True)),
23+
('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.organisation')),
24+
],
25+
),
26+
migrations.AddField(
27+
model_name='organisationmember',
28+
name='network_policies',
29+
field=models.ManyToManyField(blank=True, related_name='members', to='api.networkaccesspolicy'),
30+
),
31+
migrations.AddField(
32+
model_name='serviceaccount',
33+
name='network_policies',
34+
field=models.ManyToManyField(blank=True, related_name='service_accounts', to='api.networkaccesspolicy'),
35+
),
36+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.16 on 2025-04-27 09:31
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('api', '0100_networkaccesspolicy_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='networkaccesspolicy',
15+
name='updated_at',
16+
field=models.DateTimeField(auto_now=True),
17+
),
18+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.16 on 2025-04-28 10:14
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('api', '0101_networkaccesspolicy_updated_at'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='networkaccesspolicy',
16+
name='created_by',
17+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='network_policies_created', to='api.organisationmember'),
18+
),
19+
migrations.AddField(
20+
model_name='networkaccesspolicy',
21+
name='updated_by',
22+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='network_policies_updated', to='api.organisationmember'),
23+
),
24+
]

backend/api/models.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,38 @@ def __str__(self):
185185
return f"{self.name} ({self.organisation.name})"
186186

187187

188+
class NetworkAccessPolicy(models.Model):
189+
id = models.TextField(default=uuid4, primary_key=True, editable=False)
190+
name = models.CharField(max_length=100)
191+
organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE)
192+
allowed_ips = models.TextField(
193+
help_text="Comma-separated list of IP addresses or CIDR ranges (e.g. 192.168.1.1, 10.0.0.0/24)"
194+
)
195+
is_global = models.BooleanField(default=True)
196+
created_at = models.DateTimeField(auto_now_add=True)
197+
created_by = models.ForeignKey(
198+
"OrganisationMember",
199+
on_delete=models.CASCADE,
200+
blank=True,
201+
null=True,
202+
related_name="network_policies_created",
203+
)
204+
updated_at = models.DateTimeField(auto_now=True)
205+
updated_by = models.ForeignKey(
206+
"OrganisationMember",
207+
on_delete=models.CASCADE,
208+
blank=True,
209+
null=True,
210+
related_name="network_policies_updated",
211+
)
212+
213+
def get_ip_list(self):
214+
return [ip.strip() for ip in self.allowed_ips.split(",") if ip.strip()]
215+
216+
def __str__(self):
217+
return self.name
218+
219+
188220
class OrganisationMember(models.Model):
189221

190222
id = models.TextField(default=uuid4, primary_key=True, editable=False)
@@ -205,6 +237,9 @@ class OrganisationMember(models.Model):
205237
identity_key = models.CharField(max_length=256, null=True, blank=True)
206238
wrapped_keyring = models.TextField(blank=True)
207239
wrapped_recovery = models.TextField(blank=True)
240+
network_policies = models.ManyToManyField(
241+
NetworkAccessPolicy, blank=True, related_name="members"
242+
)
208243
created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True)
209244
updated_at = models.DateTimeField(auto_now=True)
210245
deleted_at = models.DateTimeField(null=True, blank=True)
@@ -241,6 +276,9 @@ class ServiceAccount(models.Model):
241276
identity_key = models.CharField(max_length=256, null=True, blank=True)
242277
server_wrapped_keyring = models.TextField(null=True)
243278
server_wrapped_recovery = models.TextField(null=True)
279+
network_policies = models.ManyToManyField(
280+
NetworkAccessPolicy, blank=True, related_name="service_accounts"
281+
)
244282
created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True)
245283
updated_at = models.DateTimeField(auto_now=True)
246284
deleted_at = models.DateTimeField(null=True, blank=True)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# permissions.py
2+
3+
from api.models import NetworkAccessPolicy, Organisation
4+
from rest_framework.permissions import BasePermission
5+
from itertools import chain
6+
7+
8+
class IsIPAllowed(BasePermission):
9+
"""
10+
Checks if the client's IP is allowed based on attached network access policies.
11+
"""
12+
13+
message = (
14+
"Access denied: a network access policy restricts access from your IP address."
15+
)
16+
17+
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")
22+
23+
def has_permission(self, request, view):
24+
ip = self.get_client_ip(request)
25+
26+
org_member = request.auth.get("org_member", None)
27+
service_account = request.auth.get("service_account", None)
28+
service_token = request.auth.get("service_token")
29+
30+
org = None
31+
account_policies = NetworkAccessPolicy.objects.none()
32+
33+
if org_member:
34+
account_policies = org_member.network_policies.all()
35+
org = org_member.organisation
36+
elif service_account:
37+
account_policies = service_account.network_policies.all()
38+
org = service_account.organisation
39+
elif service_token:
40+
org = service_token.app.organisation
41+
42+
if org is None or org.plan == Organisation.FREE_PLAN:
43+
return True
44+
else:
45+
from ee.access.utils.network import is_ip_allowed
46+
47+
global_policies = (
48+
(
49+
NetworkAccessPolicy.objects.filter(organisation=org, is_global=True)
50+
if org
51+
else NetworkAccessPolicy.objects.none()
52+
)
53+
if org.plan == Organisation.ENTERPRISE_PLAN
54+
else []
55+
)
56+
57+
all_policies = list(chain(account_policies, global_policies))
58+
59+
if not all_policies:
60+
return True # Allow if no policies defined
61+
62+
return is_ip_allowed(ip, all_policies)

backend/api/utils/access/roles.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"ServiceAccountTokens": ["create", "read", "update", "delete"],
1515
"Roles": ["create", "read", "update", "delete"],
1616
"IntegrationCredentials": ["create", "read", "update", "delete"],
17+
"NetworkAccessPolicies": ["create", "read", "update", "delete"],
1718
},
1819
"app_permissions": {
1920
"Environments": ["create", "read", "update", "delete"],
@@ -43,6 +44,7 @@
4344
"ServiceAccountTokens": ["create", "read", "update", "delete"],
4445
"Roles": ["create", "read", "update", "delete"],
4546
"IntegrationCredentials": ["create", "read", "update", "delete"],
47+
"NetworkAccessPolicies": ["create", "read", "update", "delete"],
4648
},
4749
"app_permissions": {
4850
"Environments": ["create", "read", "update", "delete"],
@@ -71,6 +73,7 @@
7173
"ServiceAccountTokens": ["create", "read", "update", "delete"],
7274
"Roles": ["create", "read", "update", "delete"],
7375
"IntegrationCredentials": ["create", "read", "update", "delete"],
76+
"NetworkAccessPolicies": ["create", "read", "update", "delete"],
7477
},
7578
"app_permissions": {
7679
"Environments": ["read", "create", "update"],
@@ -103,6 +106,7 @@
103106
"read",
104107
"update",
105108
],
109+
"NetworkAccessPolicies": ["read"],
106110
},
107111
"app_permissions": {
108112
"Environments": ["read", "create", "update"],
@@ -131,6 +135,7 @@
131135
"ServiceAccountTokens": ["read"],
132136
"Roles": ["read"],
133137
"IntegrationCredentials": ["read"],
138+
"NetworkAccessPolicies": ["read"],
134139
},
135140
"app_permissions": {
136141
"Environments": ["read", "create", "update", "delete"],

backend/api/views/auth.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import requests
22
import json
33
import base64
4+
from api.utils.access.middleware import IsIPAllowed
45
import jwt
56
import os
67
from api.serializers import (
@@ -174,12 +175,12 @@ def complete_login(self, request, app, token, **kwargs):
174175
if emails:
175176
# First try to get primary email
176177
for email_obj in emails:
177-
if email_obj.get('primary'):
178-
extra_data["email"] = email_obj['email']
178+
if email_obj.get("primary"):
179+
extra_data["email"] = email_obj["email"]
179180
break
180181
# If no primary email found, use the first one
181182
if not extra_data.get("email") and len(emails) > 0:
182-
extra_data["email"] = emails[0]['email']
183+
extra_data["email"] = emails[0]["email"]
183184

184185
email = extra_data["email"]
185186

@@ -332,7 +333,11 @@ def service_token_kms(request):
332333

333334

334335
@api_view(["GET"])
335-
@permission_classes([AllowAny])
336+
@permission_classes(
337+
[
338+
AllowAny,
339+
]
340+
)
336341
def secrets_tokens(request):
337342
auth_token = request.headers["authorization"]
338343

backend/api/views/secrets.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import json
3131
from api.content_negotiation import CamelCaseContentNegotiation
32+
from api.utils.access.middleware import IsIPAllowed
3233
from rest_framework.views import APIView
3334
from rest_framework.permissions import IsAuthenticated
3435
from rest_framework.exceptions import PermissionDenied
@@ -44,7 +45,7 @@
4445

4546
class E2EESecretsView(APIView):
4647
authentication_classes = [PhaseTokenAuthentication]
47-
permission_classes = [IsAuthenticated]
48+
permission_classes = [IsAuthenticated, IsIPAllowed]
4849
content_negotiation_class = CamelCaseContentNegotiation
4950

5051
def initial(self, request, *args, **kwargs):
@@ -351,7 +352,7 @@ def delete(self, request, *args, **kwargs):
351352

352353
class PublicSecretsView(APIView):
353354
authentication_classes = [PhaseTokenAuthentication]
354-
permission_classes = [IsAuthenticated]
355+
permission_classes = [IsAuthenticated, IsIPAllowed]
355356
renderer_classes = [
356357
CamelCaseJSONRenderer,
357358
]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from graphql import GraphQLResolveInfo
2+
from graphql import GraphQLError
3+
from api.models import NetworkAccessPolicy, Organisation, OrganisationMember
4+
5+
from itertools import chain
6+
7+
8+
class IPRestrictedError(GraphQLError):
9+
def __init__(self, organisation_name: str):
10+
super().__init__(
11+
message=f"Your IP address is not allowed to access {organisation_name}",
12+
extensions={
13+
"code": "IP_RESTRICTED",
14+
"organisation_name": organisation_name,
15+
},
16+
)
17+
18+
19+
class IPWhitelistMiddleware:
20+
"""
21+
Graphene middleware to enforce network access policy for human users
22+
based on their organisation membership and IP address.
23+
"""
24+
25+
def resolve(self, next, root, info: GraphQLResolveInfo, **kwargs):
26+
request = info.context
27+
user = getattr(request, "user", None)
28+
29+
organisation_id = kwargs.get("organisation_id")
30+
if not user or not user.is_authenticated:
31+
raise GraphQLError("Authentication required")
32+
33+
if not organisation_id:
34+
# If the operation doesn't involve an org, skip check
35+
return next(root, info, **kwargs)
36+
37+
org = Organisation.objects.get(id=organisation_id)
38+
39+
if org.plan == Organisation.FREE_PLAN:
40+
return next(root, info, **kwargs)
41+
42+
else:
43+
from ee.access.utils.network import is_ip_allowed
44+
45+
try:
46+
org_member = OrganisationMember.objects.get(
47+
organisation_id=organisation_id,
48+
user_id=user.userId,
49+
deleted_at__isnull=True,
50+
)
51+
except OrganisationMember.DoesNotExist:
52+
raise GraphQLError("You are not a member of this organisation")
53+
54+
ip = self.get_client_ip(request)
55+
56+
account_policies = org_member.network_policies.all()
57+
global_policies = (
58+
NetworkAccessPolicy.objects.filter(
59+
organisation_id=organisation_id, is_global=True
60+
)
61+
if org.plan == Organisation.ENTERPRISE_PLAN
62+
else []
63+
)
64+
65+
all_policies = list(chain(account_policies, global_policies))
66+
67+
if not all_policies or is_ip_allowed(ip, all_policies):
68+
return next(root, info, **kwargs)
69+
70+
raise IPRestrictedError(org_member.organisation.name)
71+
72+
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")

0 commit comments

Comments
 (0)