Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bf8ec33
feat: environments api
rohan-chaturvedi Mar 9, 2026
64d6348
fix: use cache-and-network policy on app access pages
rohan-chaturvedi Mar 9, 2026
f71eed0
feat: add apps api
rohan-chaturvedi Mar 9, 2026
ded69a4
feat: rest api for roles, service accounts
rohan-chaturvedi Mar 13, 2026
77e97e5
fix: validate environments when assigning account app access
rohan-chaturvedi Mar 13, 2026
c23a7eb
feat: validate json permissions when creating or updating roles
rohan-chaturvedi Mar 13, 2026
aa10e2b
wip: audit logs
rohan-chaturvedi Mar 14, 2026
6fdcd85
Merge branch 'main' into api--apps-envs-accounts
rohan-chaturvedi Mar 16, 2026
9bb5fc3
Merge branch 'main' into api--apps-envs-accounts
rohan-chaturvedi Mar 16, 2026
5927821
fix: migration graph
rohan-chaturvedi Mar 16, 2026
0184d81
chore: regenerate graphql types
rohan-chaturvedi Mar 16, 2026
b26d78c
fix: enhance captured log event data
rohan-chaturvedi Mar 16, 2026
2c07ca1
feat: rescale and improve audit logs ui
rohan-chaturvedi Mar 16, 2026
6a5fc0d
Merge branch 'main' into api--apps-envs-accounts
rohan-chaturvedi Mar 17, 2026
c9cd7b5
feat: log org invites
rohan-chaturvedi Mar 17, 2026
fc35dcb
fix: access and permissions checks for apps and envs api
rohan-chaturvedi Mar 18, 2026
e0b64ab
fix: remove logs api route
rohan-chaturvedi Mar 18, 2026
b144c2a
feat: add api to manage users and invites, fix service account attrib…
rohan-chaturvedi Mar 18, 2026
9d9f803
fix: use enum values for comparisons
rohan-chaturvedi Mar 18, 2026
e653c1d
fix: apps api tests
rohan-chaturvedi Mar 18, 2026
ccea3e8
feat: filter logs for viewer access level
rohan-chaturvedi Mar 20, 2026
f753b0b
feat: misc improvements to log detail, clarity and correctness
rohan-chaturvedi Mar 20, 2026
3a3e158
feat: several improvements to audit log styling, rendering and detail…
rohan-chaturvedi Mar 20, 2026
53ef095
Merge branch 'main' into api--apps-envs-accounts
rohan-chaturvedi Mar 24, 2026
89a691a
Merge branch 'main' into api--apps-envs-accounts
rohan-chaturvedi Mar 24, 2026
34eefd3
Merge branch 'main' into api--apps-envs-accounts
rohan-chaturvedi Mar 27, 2026
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
164 changes: 132 additions & 32 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from api.models import DynamicSecret, Environment, Secret
from api.utils.access.permissions import (
service_account_can_access_environment,
user_can_access_app,
user_can_access_environment,
)
from rest_framework import authentication, exceptions
Expand Down Expand Up @@ -99,31 +100,93 @@ def authenticate(self, request):

# Try resolving env from query params
else:
try:
app_id = request.GET.get("app_id")
env_name = request.GET.get("env")
if not app_id:
raise exceptions.AuthenticationFailed(
"Missing app_id parameter"
)
if not env_name:
raise exceptions.AuthenticationFailed("Missing env parameter")
# Pre-fetch app and organisation
env = Environment.objects.select_related("app__organisation").get(
app_id=app_id, name__iexact=env_name
)
except Environment.DoesNotExist:
# Check if the app exists to give a more specific error
app_id = request.GET.get("app_id")
env_name = request.GET.get("env")

if app_id and env_name:
# Resolve environment from app_id + env name
try:
env = Environment.objects.select_related(
"app__organisation"
).get(app_id=app_id, name__iexact=env_name)
except Environment.DoesNotExist:
App = apps.get_model("api", "App")
if not App.objects.filter(id=app_id).exists():
raise exceptions.NotFound(
f"App with ID {app_id} not found"
)
else:
raise exceptions.NotFound(
f"Environment '{env_name}' not found in App {app_id}"
)

elif app_id and not env_name:
# App-only mode: resolve app directly (no environment needed)
App = apps.get_model("api", "App")
if not App.objects.filter(id=app_id).exists():
raise exceptions.NotFound(f"App with ID {app_id} not found")
else:
try:
app = App.objects.select_related("organisation").get(
id=app_id
)
except App.DoesNotExist:
raise exceptions.NotFound(
f"Environment '{env_name}' not found in App {app_id}"
f"App with ID {app_id} not found"
)
auth["app"] = app

else:
# No app_id in query params — check URL kwargs for env_id
# (used by detail endpoints like /environments/<env_id>/)
env_id_from_url = None
if (
hasattr(request, "resolver_match")
and request.resolver_match
):
env_id_from_url = (
request.resolver_match.kwargs.get("env_id")
)

if env_id_from_url:
try:
env_lookup = Environment.objects.select_related(
"app__organisation"
).get(id=env_id_from_url)
auth["app"] = env_lookup.app
except Environment.DoesNotExist:
raise exceptions.NotFound("Environment not found")
else:
# Check URL kwargs for app_id
# (used by detail endpoints like /apps/<app_id>/)
app_id_from_url = None
if (
hasattr(request, "resolver_match")
and request.resolver_match
):
app_id_from_url = (
request.resolver_match.kwargs.get("app_id")
)

if app_id_from_url:
App = apps.get_model("api", "App")
try:
app = App.objects.select_related(
"organisation"
).get(id=app_id_from_url)
except App.DoesNotExist:
raise exceptions.NotFound(
f"App with ID {app_id_from_url} not found"
)
auth["app"] = app
else:
# Org-only mode: no app, no env
# Organisation resolved from token below
auth["org_only"] = True

auth["environment"] = env

# When env is resolved, also populate auth["app"] for convenience
if env is not None:
auth["app"] = env.app

if token_type == "User":
try:
org_member = get_org_member_from_user_token(auth_token)
Expand All @@ -135,10 +198,26 @@ def authenticate(self, request):
auth["org_member"] = org_member
user = org_member.user

if not user_can_access_environment(user.userId, env.id):
raise exceptions.PermissionDenied("User cannot access this environment")
if auth.get("org_only"):
# Org-only mode: resolve organisation from the member
auth["organisation"] = org_member.organisation
elif env:
if not user_can_access_environment(user.userId, env.id):
raise exceptions.PermissionDenied(
"User cannot access this environment"
)
else:
# App-only mode
if not user_can_access_app(user.userId, auth["app"].id):
raise exceptions.PermissionDenied(
"User cannot access this app"
)

elif token_type == "Service":
if env is None:
raise exceptions.AuthenticationFailed(
"Service tokens require an environment context"
)
service_token = get_service_token(auth_token)
auth["service_token"] = service_token
user = service_token.created_by.user
Expand All @@ -158,30 +237,51 @@ def authenticate(self, request):
auth["service_account"] = service_account
auth["service_account_token"] = service_token

if not service_account_can_access_environment(
service_account.id, env.id
):
raise exceptions.AuthenticationFailed(
"Service account cannot access this environment"
)
if auth.get("org_only"):
# Org-only mode: resolve organisation from the SA
auth["organisation"] = service_account.organisation
elif env:
if not service_account_can_access_environment(
service_account.id, env.id
):
raise exceptions.AuthenticationFailed(
"Service account cannot access this environment"
)
else:
# App-only mode: check SA is a member of this app
if not auth["app"].service_accounts.filter(
id=service_account.id, deleted_at=None
).exists():
raise exceptions.AuthenticationFailed(
"Service account cannot access this app"
)
except exceptions.AuthenticationFailed:
raise
except exceptions.NotFound:
raise
except Exception as ex:
# Distinguish between ServiceAccount not found and other potential errors
ServiceAccount = apps.get_model("api", "ServiceAccount")
try:
# Attempt to get the service account again to confirm if it exists
get_service_account_from_token(auth_token)
# If it exists, the error was likely the environment access check
raise exceptions.AuthenticationFailed(
"Service account cannot access this environment"
)
# If it exists, the error was likely the access check
if env:
raise exceptions.AuthenticationFailed(
"Service account cannot access this environment"
)
else:
raise exceptions.AuthenticationFailed(
"Service account cannot access this app"
)
except ServiceAccount.DoesNotExist:
raise exceptions.NotFound("Service account not found")
except (
Exception
) as ex: # Catch any other unexpected error during the re-check
logger.debug(f"Authentication error: {ex}")
raise exceptions.AuthenticationFailed(
f"Authentication error. Please check your authentication token or App / Environment access."
"Authentication error. Please check your authentication token or App / Environment access."
)

return (user, auth)
12 changes: 10 additions & 2 deletions backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,18 @@ def send_login_email(request, email, full_name, provider):
)


def _get_invite_sender_name(invite):
if invite.invited_by:
return get_org_member_name(invite.invited_by)
if invite.invited_by_service_account:
return invite.invited_by_service_account.name
return invite.organisation.name


def send_invite_email(invite):
organisation = invite.organisation.name

invited_by_name = get_org_member_name(invite.invited_by)
invited_by_name = _get_invite_sender_name(invite)

invite_code = encode_string_to_base64(str(invite.id))

Expand Down Expand Up @@ -116,7 +124,7 @@ def send_user_joined_email(invite, new_member):

owner_name = get_org_member_name(owner)

invited_by_name = get_org_member_name(invite.invited_by)
invited_by_name = _get_invite_sender_name(invite)

if owner_name == invited_by_name:
invited_by_name = "you"
Expand Down
39 changes: 39 additions & 0 deletions backend/api/migrations/0118_auditevent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 4.2.28 on 2026-03-13 11:18

from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid


class Migration(migrations.Migration):

dependencies = [
('api', '0117_alter_environmentsync_service_and_more'),
]

operations = [
migrations.CreateModel(
name='AuditEvent',
fields=[
('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('event_type', models.CharField(choices=[('C', 'Create'), ('R', 'Read'), ('U', 'Update'), ('D', 'Delete'), ('A', 'Access')], max_length=1)),
('resource_type', models.CharField(choices=[('app', 'App'), ('env', 'Environment'), ('role', 'Role'), ('sa', 'ServiceAccount'), ('member', 'OrganisationMember'), ('policy', 'NetworkAccessPolicy'), ('pat', 'UserToken'), ('sa_token', 'ServiceAccountToken'), ('svc_token', 'ServiceToken')], max_length=10)),
('resource_id', models.TextField()),
('actor_type', models.CharField(choices=[('user', 'User'), ('sa', 'ServiceAccount')], max_length=10)),
('actor_id', models.TextField()),
('actor_metadata', models.JSONField(default=dict)),
('resource_metadata', models.JSONField(default=dict)),
('old_values', models.JSONField(blank=True, null=True)),
('new_values', models.JSONField(blank=True, null=True)),
('description', models.TextField(blank=True, default='')),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True, default='')),
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_events', to='api.organisation')),
],
options={
'indexes': [models.Index(fields=['resource_type', 'resource_id', '-timestamp'], name='audit_resource_history_idx'), models.Index(fields=['organisation', '-timestamp'], name='audit_org_activity_idx'), models.Index(fields=['actor_type', 'actor_id', '-timestamp'], name='audit_actor_activity_idx')],
},
),
]
18 changes: 18 additions & 0 deletions backend/api/migrations/0119_auditevent_invite_resource_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-17 14:46

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0118_auditevent'),
]

operations = [
migrations.AlterField(
model_name='auditevent',
name='resource_type',
field=models.CharField(choices=[('app', 'App'), ('env', 'Environment'), ('role', 'Role'), ('sa', 'ServiceAccount'), ('member', 'OrganisationMember'), ('policy', 'NetworkAccessPolicy'), ('pat', 'UserToken'), ('sa_token', 'ServiceAccountToken'), ('svc_token', 'ServiceToken'), ('invite', 'Invite')], max_length=10),
),
]
24 changes: 24 additions & 0 deletions backend/api/migrations/0120_invite_sa_inviter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.29 on 2026-03-18 08:56

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('api', '0119_auditevent_invite_resource_type'),
]

operations = [
migrations.AlterField(
model_name='organisationmemberinvite',
name='invited_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.organisationmember'),
),
migrations.AddField(
model_name='organisationmemberinvite',
name='invited_by_service_account',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.serviceaccount'),
),
]
Loading
Loading