From bf8ec333477b2d032355cf6ee6a0233af1bb330a Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 9 Mar 2026 23:17:09 +0530 Subject: [PATCH 01/20] feat: environments api Signed-off-by: rohan --- backend/api/auth.py | 135 ++- backend/api/throttling.py | 9 + backend/api/utils/environments.py | 374 +++++++++ backend/api/views/environments.py | 291 +++++++ backend/backend/urls.py | 3 + .../tests/api/views/test_environments_api.py | 780 ++++++++++++++++++ backend/tests/utils/test_environments.py | 626 ++++++++++++++ 7 files changed, 2186 insertions(+), 32 deletions(-) create mode 100644 backend/api/utils/environments.py create mode 100644 backend/api/views/environments.py create mode 100644 backend/tests/api/views/test_environments_api.py create mode 100644 backend/tests/utils/test_environments.py diff --git a/backend/api/auth.py b/backend/api/auth.py index 190d9308f..93d0164b4 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -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 @@ -99,31 +100,70 @@ 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_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: + raise exceptions.AuthenticationFailed( + "Missing app_id parameter" ) 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) @@ -135,10 +175,23 @@ 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 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 @@ -158,22 +211,40 @@ 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 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 ( @@ -181,7 +252,7 @@ def authenticate(self, request): ) 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) diff --git a/backend/api/throttling.py b/backend/api/throttling.py index b5289fee3..71766cb3e 100644 --- a/backend/api/throttling.py +++ b/backend/api/throttling.py @@ -43,6 +43,15 @@ def allow_request(self, request, view): new_rate = self.get_rate_for_plan(plan) except AttributeError: pass + else: + # App-only mode fallback (e.g. environments CRUD API) + app = request.auth.get("app") + if app: + try: + plan = app.organisation.plan + new_rate = self.get_rate_for_plan(plan) + except AttributeError: + pass # Update the throttle configuration for this specific request self.rate = new_rate diff --git a/backend/api/utils/environments.py b/backend/api/utils/environments.py new file mode 100644 index 000000000..fcf04c65c --- /dev/null +++ b/backend/api/utils/environments.py @@ -0,0 +1,374 @@ +""" +Server-side utilities for creating environments and wrapping cryptographic +keys for users and service accounts. + +This module mirrors the client-side ``createNewEnv`` flow +(frontend/utils/crypto/environments.ts) so that environments can be +provisioned entirely on the server — e.g. via the public REST API — without +requiring any client-side cryptography. + +The high-level flow: + +1. Generate a random env **seed** and **salt** (32 bytes each). +2. Derive the env keypair from the seed (``crypto_kx_seed_keypair``). +3. Wrap (asymmetrically encrypt) the seed and salt for: + a. Every global-access user (Owner + Admin roles). + b. Every SSK service account that has access to the parent app. + c. The server itself (``ServerEnvironmentKey``). +4. Persist ``Environment``, ``EnvironmentKey`` (per-user & per-SA), and + ``ServerEnvironmentKey`` records inside a single atomic transaction. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import List, Optional, Tuple + +from django.db import transaction +from django.db.models import Max, Q + +from api.models import ( + App, + Environment, + EnvironmentKey, + OrganisationMember, + Role, + ServerEnvironmentKey, + ServiceAccount, +) +from api.utils.crypto import ( + decrypt_asymmetric, + encrypt_asymmetric, + env_keypair, + get_server_keypair, + random_hex, +) + +logger = logging.getLogger(__name__) + +ENV_NAME_RE = re.compile(r"^[a-zA-Z0-9\-_]{1,64}$") + + +# --------------------------------------------------------------------------- +# Low-level helpers +# --------------------------------------------------------------------------- + + +def _generate_env_seed() -> str: + """Return a random 32-byte hex seed for a new environment.""" + return random_hex(32) + + +def _generate_env_salt() -> str: + """Return a random 32-byte hex salt for a new environment.""" + return random_hex(32) + + +def _wrap_env_secrets_for_key( + seed: str, + salt: str, + public_key_hex: str, +) -> Tuple[str, str]: + """ + Wrap (asymmetrically encrypt) an environment's seed and salt for a + given public key. + + The *public_key_hex* must already be a Curve25519 (X25519) key-exchange + public key. For Ed25519 identity keys, call ``ed25519_to_kx`` first. + + Returns ``(wrapped_seed, wrapped_salt)``. + """ + wrapped_seed = encrypt_asymmetric(seed, public_key_hex) + wrapped_salt = encrypt_asymmetric(salt, public_key_hex) + return wrapped_seed, wrapped_salt + + +def _wrap_for_user( + seed: str, + salt: str, + member: OrganisationMember, +) -> Tuple[str, str]: + """ + Wrap env secrets for an ``OrganisationMember`` whose ``identity_key`` + is stored as an Ed25519 public key. + + Returns ``(wrapped_seed, wrapped_salt)``. + """ + kx_pub = _ed25519_pk_to_curve25519(member.identity_key) + return _wrap_env_secrets_for_key(seed, salt, kx_pub) + + +def _ed25519_pk_to_curve25519(ed25519_pub_hex: str) -> str: + """Convert an Ed25519 public key (hex) to a Curve25519 public key (hex).""" + from nacl.bindings import crypto_sign_ed25519_pk_to_curve25519 + + return crypto_sign_ed25519_pk_to_curve25519( + bytes.fromhex(ed25519_pub_hex) + ).hex() + + +def _wrap_for_server(seed: str, salt: str) -> Tuple[str, str, str]: + """ + Wrap env secrets for the server. + + Returns ``(wrapped_seed, wrapped_salt, server_identity_key_hex)``. + """ + pk, _sk = get_server_keypair() + wrapped_seed, wrapped_salt = _wrap_env_secrets_for_key(seed, salt, pk.hex()) + return wrapped_seed, wrapped_salt, pk.hex() + + +def _wrap_for_service_account( + seed: str, + salt: str, + service_account: ServiceAccount, +) -> Optional[Tuple[str, str]]: + """ + Wrap env secrets for a service account that uses server-side key + management (SSK). + + The SA's ``server_wrapped_keyring`` is decrypted to obtain the SA's + Ed25519 public key, which is then converted to Curve25519 for wrapping. + + Returns ``(wrapped_seed, wrapped_salt)`` or ``None`` if the SA does not + have SSK enabled. + """ + if not service_account.server_wrapped_keyring: + return None + + pk, sk = get_server_keypair() + keyring_json = decrypt_asymmetric( + service_account.server_wrapped_keyring, sk.hex(), pk.hex() + ) + keyring = json.loads(keyring_json) + kx_pub = _ed25519_pk_to_curve25519(keyring["publicKey"]) + return _wrap_env_secrets_for_key(seed, salt, kx_pub) + + +# --------------------------------------------------------------------------- +# Query helpers +# --------------------------------------------------------------------------- + + +def get_global_access_members(organisation) -> List[OrganisationMember]: + """ + Return all active ``OrganisationMember`` records whose role grants + global access (Owner, Admin, or any custom role with + ``permissions.global_access == True``). + """ + global_access_roles = Role.objects.filter( + Q(organisation=organisation) + & ( + Q(name__iexact="owner") + | Q(name__iexact="admin") + | Q(permissions__global_access=True) + ) + ) + + return list( + OrganisationMember.objects.filter( + organisation=organisation, + role__in=global_access_roles, + deleted_at=None, + ).select_related("role") + ) + + +def get_ssk_service_accounts_for_app(app: App) -> List[ServiceAccount]: + """ + Return service accounts associated with the given app that have + server-side key management enabled (i.e. ``server_wrapped_keyring`` + is not null). + """ + return list( + app.service_accounts.filter( + server_wrapped_keyring__isnull=False, + deleted_at=None, + ) + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def create_environment( + app: App, + name: str, + env_type: str = "custom", + requesting_user: Optional[OrganisationMember] = None, + requesting_sa: Optional[ServiceAccount] = None, +) -> Environment: + """ + Create a new ``Environment`` entirely server-side, generating all + cryptographic material and wrapping keys for every principal that + needs access. + + This is the server-side equivalent of the frontend's ``createNewEnv`` + utility combined with the ``CreateEnvironmentMutation``. + + Args: + app: The parent application. + name: Environment display name (validated against ``ENV_NAME_RE``). + env_type: One of ``dev``, ``staging``, ``prod``, ``custom``. + requesting_user: The OrganisationMember making the request. + If provided and not already a global-access member, an + ``EnvironmentKey`` will be created for them. + requesting_sa: The ServiceAccount making the request. + If provided and not already covered by SSK wrapping, an + ``EnvironmentKey`` will be created using the SA's identity key. + + Returns: + The newly-created ``Environment`` instance. + + Raises: + ValueError: If the name is invalid or already exists. + """ + if not ENV_NAME_RE.match(name): + raise ValueError( + "Environment name is invalid. Only letters, numbers, hyphens " + "and underscores are allowed (max 64 characters)." + ) + + if Environment.objects.filter(app=app, name__iexact=name).exists(): + raise ValueError( + f"An environment named '{name}' already exists in this app." + ) + + # --- Cryptographic material --- + seed = _generate_env_seed() + salt = _generate_env_salt() + env_pub, _env_priv = env_keypair(seed) + + # --- Determine environment index --- + type_lower = env_type.lower() + if type_lower == "dev": + index = 0 + elif type_lower == "staging": + index = 1 + elif type_lower == "prod": + index = 2 + else: + max_index = Environment.objects.filter(app=app).aggregate( + Max("index") + )["index__max"] + index = (max_index + 1) if max_index is not None else 0 + + # --- Wrap keys for the server --- + server_wrapped_seed, server_wrapped_salt, server_pk_hex = _wrap_for_server( + seed, salt + ) + + # --- Wrap keys for global-access members --- + organisation = app.organisation + global_members = get_global_access_members(organisation) + + member_wrapped: list[ + tuple[OrganisationMember, str, str] + ] = [] # (member, wrapped_seed, wrapped_salt) + + for member in global_members: + if not member.identity_key: + logger.warning( + "Skipping member %s — no identity_key set.", member.id + ) + continue + w_seed, w_salt = _wrap_for_user(seed, salt, member) + member_wrapped.append((member, w_seed, w_salt)) + + # --- Wrap keys for SSK service accounts --- + sa_wrapped: list[ + tuple[ServiceAccount, str, str] + ] = [] # (sa, wrapped_seed, wrapped_salt) + + ssk_accounts = get_ssk_service_accounts_for_app(app) + for sa in ssk_accounts: + result = _wrap_for_service_account(seed, salt, sa) + if result is not None: + sa_wrapped.append((sa, result[0], result[1])) + + # --- Ensure the requesting account gets access --- + if requesting_user is not None: + already_included = any(m.id == requesting_user.id for m, _, _ in member_wrapped) + if not already_included and requesting_user.identity_key: + w_seed, w_salt = _wrap_for_user(seed, salt, requesting_user) + member_wrapped.append((requesting_user, w_seed, w_salt)) + + if requesting_sa is not None: + already_included = any(sa.id == requesting_sa.id for sa, _, _ in sa_wrapped) + if not already_included and requesting_sa.identity_key: + kx_pub = _ed25519_pk_to_curve25519(requesting_sa.identity_key) + w_seed, w_salt = _wrap_env_secrets_for_key(seed, salt, kx_pub) + sa_wrapped.append((requesting_sa, w_seed, w_salt)) + + # --- Find the owner (for Environment.wrapped_seed / wrapped_salt) --- + owner_member = next( + (m for m, _, _ in member_wrapped if m.role and m.role.name.lower() == "owner"), + None, + ) + + if owner_member is None: + # Fallback: use the first global-access member + if member_wrapped: + owner_member = member_wrapped[0][0] + else: + raise ValueError( + "Cannot create environment: no global-access members with " + "identity keys found in the organisation." + ) + + owner_wrapped_seed, owner_wrapped_salt = next( + (ws, wsa) for m, ws, wsa in member_wrapped if m.id == owner_member.id + ) + + # --- Persist everything atomically --- + with transaction.atomic(): + environment = Environment.objects.create( + app=app, + name=name, + env_type=type_lower, + index=index, + identity_key=env_pub, + wrapped_seed=owner_wrapped_seed, + wrapped_salt=owner_wrapped_salt, + ) + + # EnvironmentKey for each global-access member + env_keys = [] + for member, w_seed, w_salt in member_wrapped: + env_keys.append( + EnvironmentKey( + environment=environment, + user=member, + identity_key=env_pub, + wrapped_seed=w_seed, + wrapped_salt=w_salt, + ) + ) + + # EnvironmentKey for each SSK service account + for sa, w_seed, w_salt in sa_wrapped: + env_keys.append( + EnvironmentKey( + environment=environment, + service_account=sa, + identity_key=env_pub, + wrapped_seed=w_seed, + wrapped_salt=w_salt, + ) + ) + + EnvironmentKey.objects.bulk_create(env_keys) + + # ServerEnvironmentKey (always created for API-driven environments) + ServerEnvironmentKey.objects.create( + environment=environment, + identity_key=env_pub, + wrapped_seed=server_wrapped_seed, + wrapped_salt=server_wrapped_salt, + ) + + return environment diff --git a/backend/api/views/environments.py b/backend/api/views/environments.py new file mode 100644 index 000000000..b243351e5 --- /dev/null +++ b/backend/api/views/environments.py @@ -0,0 +1,291 @@ +import re + +from api.auth import PhaseTokenAuthentication +from api.models import Environment, EnvironmentKey +from api.serializers import EnvironmentSerializer +from api.utils.access.permissions import ( + user_has_permission, + user_can_access_environment, + service_account_can_access_environment, +) +from api.utils.environments import create_environment +from api.utils.rest import METHOD_TO_ACTION +from api.throttling import PlanBasedRateThrottle +from api.utils.access.middleware import IsIPAllowed +from backend.quotas import can_add_environment, 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.response import Response +from rest_framework import status +from djangorestframework_camel_case.render import CamelCaseJSONRenderer + +ENV_NAME_RE = re.compile(r"^[a-zA-Z0-9\-_]{1,64}$") + + +class PublicEnvironmentsView(APIView): + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + app = request.auth.get("app") + if not app: + raise PermissionDenied("Could not resolve app from request.") + + if not app.sse_enabled: + raise PermissionDenied("SSE is not enabled for this App.") + + action = METHOD_TO_ACTION.get(request.method) + if not action: + raise PermissionDenied(f"Unsupported HTTP method: {request.method}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + # Verify the service account is a member of this app + if not app.service_accounts.filter( + id=account.id, deleted_at=None + ).exists(): + raise PermissionDenied( + "Service account does not have access to this app." + ) + + if account is not None: + organisation = app.organisation + if not user_has_permission( + account, action, "Environments", organisation, True, is_sa + ): + raise PermissionDenied( + f"You don't have permission to {action} environments." + ) + + def get(self, request, *args, **kwargs): + app = request.auth["app"] + + # Filter to environments the account actually has access to + if request.auth["auth_type"] == "User": + accessible_env_ids = EnvironmentKey.objects.filter( + environment__app=app, + user=request.auth["org_member"], + deleted_at=None, + ).values_list("environment_id", flat=True) + elif request.auth["auth_type"] == "ServiceAccount": + accessible_env_ids = EnvironmentKey.objects.filter( + environment__app=app, + service_account=request.auth["service_account"], + deleted_at=None, + ).values_list("environment_id", flat=True) + else: + accessible_env_ids = [] + + environments = Environment.objects.filter( + app=app, id__in=accessible_env_ids + ).order_by("index") + serializer = EnvironmentSerializer(environments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def post(self, request, *args, **kwargs): + app = request.auth["app"] + org = app.organisation + + name = request.data.get("name") + + if not name: + return Response( + {"error": "Missing required field: name"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not can_add_environment(app): + return Response( + {"error": "Environment quota exceeded for this app's plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not can_use_custom_envs(org): + return Response( + {"error": "Custom environments are not available on the Free plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + environment = create_environment( + app, + name, + "custom", + requesting_user=request.auth.get("org_member"), + requesting_sa=request.auth.get("service_account"), + ) + except ValueError as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = EnvironmentSerializer(environment) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class PublicEnvironmentDetailView(APIView): + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + app = request.auth.get("app") + if not app: + raise PermissionDenied("Could not resolve app from request.") + + if not app.sse_enabled: + raise PermissionDenied("SSE is not enabled for this App.") + + action = METHOD_TO_ACTION.get(request.method) + if not action: + raise PermissionDenied(f"Unsupported HTTP method: {request.method}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + # Verify the service account is a member of this app + if not app.service_accounts.filter( + id=account.id, deleted_at=None + ).exists(): + raise PermissionDenied( + "Service account does not have access to this app." + ) + + if account is not None: + organisation = app.organisation + if not user_has_permission( + account, action, "Environments", organisation, True, is_sa + ): + raise PermissionDenied( + f"You don't have permission to {action} environments." + ) + + def _get_environment(self, env_id, app): + """Resolve env by ID and verify it belongs to the authenticated app.""" + try: + env = Environment.objects.select_related("app").get(id=env_id) + except Environment.DoesNotExist: + return None + + if env.app_id != app.id: + return None + + return env + + def _check_env_access(self, request, env): + """Verify the requesting account has an EnvironmentKey for the env.""" + if request.auth["auth_type"] == "User": + user = request.auth["org_member"].user + if not user_can_access_environment(user.userId, env.id): + raise PermissionDenied( + "You don't have access to this environment." + ) + elif request.auth["auth_type"] == "ServiceAccount": + sa = request.auth["service_account"] + if not service_account_can_access_environment(sa.id, env.id): + raise PermissionDenied( + "Service account doesn't have access to this environment." + ) + + def get(self, request, env_id, *args, **kwargs): + app = request.auth["app"] + env = self._get_environment(env_id, app) + if not env: + return Response( + {"error": "Environment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + self._check_env_access(request, env) + serializer = EnvironmentSerializer(env) + return Response(serializer.data, status=status.HTTP_200_OK) + + def put(self, request, env_id, *args, **kwargs): + app = request.auth["app"] + org = app.organisation + env = self._get_environment(env_id, app) + if not env: + return Response( + {"error": "Environment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + self._check_env_access(request, env) + + name = request.data.get("name") + if not name: + return Response( + {"error": "Missing required field: name"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not ENV_NAME_RE.match(name): + return Response( + { + "error": "Environment name is invalid. Only letters, numbers, " + "hyphens and underscores are allowed (max 64 characters)." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if env.env_type not in ("dev", "staging", "prod") and not can_use_custom_envs(org): + return Response( + {"error": "Custom environments are not available on the Free plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Check for duplicate name (case-insensitive, excluding self) + if ( + Environment.objects.filter(app=app, name__iexact=name) + .exclude(id=env.id) + .exists() + ): + return Response( + {"error": f"An environment named '{name}' already exists in this app."}, + status=status.HTTP_409_CONFLICT, + ) + + env.name = name + env.save() + + serializer = EnvironmentSerializer(env) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, env_id, *args, **kwargs): + app = request.auth["app"] + org = app.organisation + env = self._get_environment(env_id, app) + if not env: + return Response( + {"error": "Environment not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + self._check_env_access(request, env) + + if env.env_type not in ("dev", "staging", "prod") and not can_use_custom_envs(org): + return Response( + {"error": "Custom environments are not available on the Free plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + env.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index ad0d5cf0e..3de1893be 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -4,6 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from api.views.lockbox import LockboxView from api.views.graphql import PrivateGraphQLView +from api.views.environments import PublicEnvironmentsView, PublicEnvironmentDetailView from api.views.secrets import E2EESecretsView, PublicSecretsView from api.views.auth import ( logout_view, @@ -44,6 +45,8 @@ public_urls = [ path("public/", root_endpoint), path("public/v1/secrets/", PublicSecretsView.as_view()), + path("public/v1/environments/", PublicEnvironmentsView.as_view()), + path("public/v1/environments//", PublicEnvironmentDetailView.as_view()), path( "public/v1/secrets/dynamic/", include("ee.integrations.secrets.dynamic.rest.urls"), diff --git a/backend/tests/api/views/test_environments_api.py b/backend/tests/api/views/test_environments_api.py new file mode 100644 index 000000000..d7ea69373 --- /dev/null +++ b/backend/tests/api/views/test_environments_api.py @@ -0,0 +1,780 @@ +import uuid +import pytest +from unittest.mock import Mock, MagicMock, patch +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework import status + +from api.views.environments import PublicEnvironmentsView, PublicEnvironmentDetailView + + +# ──────────────────────────────────────────────────────────────────── +# Shared test helpers +# ──────────────────────────────────────────────────────────────────── + + +def _make_app(sse_enabled=True, plan="PR", org_id=None): + org = Mock() + org.id = org_id or uuid.uuid4() + org.plan = plan + org.organisation_id = org.id + + app = Mock() + app.id = uuid.uuid4() + app.sse_enabled = sse_enabled + app.organisation = org + app.organisation_id = org.id + return app + + +def _make_env(app=None, name="staging", env_type="staging", index=1, env_id=None): + env = Mock(spec=["id", "name", "env_type", "index", "app", "app_id", "save", "delete", + "created_at", "updated_at"]) + env.id = env_id or uuid.uuid4() + env.name = name + env.env_type = env_type + env.index = index + env.app = app + env.app_id = app.id if app else uuid.uuid4() + env.created_at = "2024-01-01T00:00:00Z" + env.updated_at = "2024-01-01T00:00:00Z" + return env + + +def _make_user(): + user = Mock() + user.userId = uuid.uuid4() + user.id = user.userId + user.is_authenticated = True + user.is_active = True + return user + + +def _make_auth(app, auth_type="User", org_member=None, service_account=None): + return { + "token": "Bearer User test_token", + "auth_type": auth_type, + "app": app, + "environment": None, + "org_member": org_member, + "service_token": None, + "service_account": service_account, + "service_account_token": None, + } + + +def _build_request(method, url, app, data=None, auth_type="User", role_name="Owner"): + """Build an API request with force_authenticate + custom auth dict.""" + factory = APIRequestFactory() + user = _make_user() + + if method == "get": + request = factory.get(url) + elif method == "post": + request = factory.post(url, data=data, format="json") + elif method == "put": + request = factory.put(url, data=data, format="json") + elif method == "delete": + request = factory.delete(url) + else: + raise ValueError(f"Unknown method: {method}") + + org_member = Mock() + org_member.id = uuid.uuid4() + org_member.user = user + org_member.deleted_at = None + org_member.role = Mock() + org_member.role.name = role_name + + sa = None + if auth_type == "ServiceAccount": + sa = Mock() + sa.id = uuid.uuid4() + sa.organisation_id = app.organisation_id + + auth = _make_auth( + app, + auth_type=auth_type, + org_member=org_member if auth_type == "User" else None, + service_account=sa, + ) + + # force_authenticate sets request.user and request.auth on the DRF Request wrapper + force_authenticate(request, user=user, token=auth) + return request + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicEnvironmentsView (list + create) +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicEnvironmentsViewList: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicEnvironmentsView.as_view() + self.app = _make_app() + + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.EnvironmentKey") + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_list_environments(self, _ip, _throttle, _perm, mock_env_model, mock_ek_model, mock_serializer): + env_ids = [uuid.uuid4(), uuid.uuid4(), uuid.uuid4()] + mock_ek_model.objects.filter.return_value.values_list.return_value = env_ids + + mock_qs = MagicMock() + mock_qs.order_by.return_value = [Mock(), Mock(), Mock()] + mock_env_model.objects.filter.return_value = mock_qs + mock_serializer.return_value.data = [ + {"id": "1", "name": "dev"}, + {"id": "2", "name": "staging"}, + {"id": "3", "name": "prod"}, + ] + + request = _build_request("get", "/public/v1/environments/", self.app) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 3 + + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.EnvironmentKey") + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_list_environments_empty(self, _ip, _throttle, _perm, mock_env_model, mock_ek_model, mock_serializer): + mock_ek_model.objects.filter.return_value.values_list.return_value = [] + + mock_qs = MagicMock() + mock_qs.order_by.return_value = [] + mock_env_model.objects.filter.return_value = mock_qs + mock_serializer.return_value.data = [] + + request = _build_request("get", "/public/v1/environments/", self.app) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 0 + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_sse_not_enabled_returns_403(self, _ip, _throttle, _perm): + app = _make_app(sse_enabled=False) + request = _build_request("get", "/public/v1/environments/", app) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_has_permission", return_value=False) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_no_permission_returns_403(self, _ip, _throttle, _perm): + request = _build_request("get", "/public/v1/environments/", self.app) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestPublicEnvironmentsViewCreate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicEnvironmentsView.as_view() + self.app = _make_app() + + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.create_environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.can_add_environment", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_create_environment_success( + self, _ip, _throttle, _perm, _quota, _custom, mock_create, mock_serializer + ): + new_env = _make_env(app=self.app, name="test-env", env_type="custom", index=3) + mock_create.return_value = new_env + mock_serializer.return_value.data = {"id": str(new_env.id), "name": "test-env"} + + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={"name": "test-env"}, + ) + response = self.view(request) + + assert response.status_code == status.HTTP_201_CREATED + mock_create.assert_called_once() + call_args = mock_create.call_args + assert call_args[0] == (self.app, "test-env", "custom") + assert call_args[1]["requesting_user"] is not None + assert call_args[1]["requesting_sa"] is None + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_create_missing_name_returns_400(self, _ip, _throttle, _perm): + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.environments.create_environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.can_add_environment", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_create_invalid_name_returns_400( + self, _ip, _throttle, _perm, _quota, _custom, mock_create + ): + mock_create.side_effect = ValueError("Environment name is invalid.") + + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={"name": "bad name!!"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.environments.create_environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.can_add_environment", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_create_duplicate_name_returns_400( + self, _ip, _throttle, _perm, _quota, _custom, mock_create + ): + mock_create.side_effect = ValueError("An environment named 'staging' already exists") + + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={"name": "staging"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.environments.can_add_environment", return_value=False) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_create_quota_exceeded_returns_403(self, _ip, _throttle, _perm, _quota): + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={"name": "new-env"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.can_use_custom_envs", return_value=False) + @patch("api.views.environments.can_add_environment", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_create_custom_env_free_plan_returns_403( + self, _ip, _throttle, _perm, _quota, _custom + ): + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={"name": "my-env"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicEnvironmentDetailView (get, update, delete) +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicEnvironmentDetailViewGet: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicEnvironmentDetailView.as_view() + self.app = _make_app() + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_get_environment_success(self, _ip, _throttle, _perm, mock_env_model, mock_serializer, _access): + env = _make_env(app=self.app, name="staging") + mock_env_model.objects.select_related.return_value.get.return_value = env + mock_serializer.return_value.data = {"id": str(env.id), "name": "staging"} + + request = _build_request("get", f"/public/v1/environments/{env.id}/", self.app) + response = self.view(request, env_id=env.id) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "staging" + + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_get_environment_not_found(self, _ip, _throttle, _perm, mock_env_model): + mock_env_model.DoesNotExist = Exception + mock_env_model.objects.select_related.return_value.get.side_effect = Exception("not found") + + request = _build_request("get", "/public/v1/environments/fake/", self.app) + response = self.view(request, env_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_get_environment_wrong_app_returns_404(self, _ip, _throttle, _perm, mock_env_model): + """Env belongs to a different app — should return 404.""" + other_app = _make_app() + env = _make_env(app=other_app, name="staging") + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request("get", f"/public/v1/environments/{env.id}/", self.app) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.environments.user_can_access_environment", return_value=False) + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_get_environment_no_env_access_returns_403(self, _ip, _throttle, _perm, mock_env_model, _access): + """User has app access but no EnvironmentKey for this env.""" + env = _make_env(app=self.app, name="staging") + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request("get", f"/public/v1/environments/{env.id}/", self.app) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestPublicEnvironmentDetailViewUpdate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicEnvironmentDetailView.as_view() + self.app = _make_app() + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.Environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_update_environment_success( + self, _ip, _throttle, _perm, _custom, mock_env_model, mock_serializer, _access + ): + env = _make_env(app=self.app, name="old-name", env_type="custom", index=3) + mock_env_model.objects.select_related.return_value.get.return_value = env + # No duplicates + mock_env_model.objects.filter.return_value.exclude.return_value.exists.return_value = False + mock_serializer.return_value.data = {"id": str(env.id), "name": "new-name"} + + request = _build_request( + "put", f"/public/v1/environments/{env.id}/", self.app, + data={"name": "new-name"}, + ) + response = self.view(request, env_id=env.id) + + assert response.status_code == status.HTTP_200_OK + assert env.name == "new-name" + env.save.assert_called_once() + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.Environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_update_invalid_name_returns_400(self, _ip, _throttle, _perm, _custom, mock_env_model, _access): + env = _make_env(app=self.app, name="staging", env_type="staging", index=1) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request( + "put", f"/public/v1/environments/{env.id}/", self.app, + data={"name": "bad name!!"}, + ) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.Environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_update_duplicate_name_returns_409(self, _ip, _throttle, _perm, _custom, mock_env_model, _access): + env = _make_env(app=self.app, name="staging", env_type="staging", index=1) + mock_env_model.objects.select_related.return_value.get.return_value = env + # Duplicate exists + mock_env_model.objects.filter.return_value.exclude.return_value.exists.return_value = True + + request = _build_request( + "put", f"/public/v1/environments/{env.id}/", self.app, + data={"name": "production"}, + ) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_409_CONFLICT + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.Environment") + @patch("api.views.environments.can_use_custom_envs", return_value=False) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_update_custom_env_free_plan_returns_403( + self, _ip, _throttle, _perm, _custom, mock_env_model, _access + ): + env = _make_env(app=self.app, name="my-env", env_type="custom", index=3) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request( + "put", f"/public/v1/environments/{env.id}/", self.app, + data={"name": "renamed"}, + ) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_update_missing_name_returns_400(self, _ip, _throttle, _perm, mock_env_model, _access): + env = _make_env(app=self.app, name="staging", env_type="staging", index=1) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request( + "put", f"/public/v1/environments/{env.id}/", self.app, + data={}, + ) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.environments.user_can_access_environment", return_value=False) + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_update_no_env_access_returns_403(self, _ip, _throttle, _perm, mock_env_model, _access): + """User has app access but no EnvironmentKey for this env.""" + env = _make_env(app=self.app, name="staging", env_type="staging", index=1) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request( + "put", f"/public/v1/environments/{env.id}/", self.app, + data={"name": "new-name"}, + ) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestPublicEnvironmentDetailViewDelete: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicEnvironmentDetailView.as_view() + self.app = _make_app() + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.Environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_delete_environment_success(self, _ip, _throttle, _perm, _custom, mock_env_model, _access): + env = _make_env(app=self.app, name="test-env", env_type="custom", index=3) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request("delete", f"/public/v1/environments/{env.id}/", self.app) + response = self.view(request, env_id=env.id) + + assert response.status_code == status.HTTP_204_NO_CONTENT + env.delete.assert_called_once() + + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_delete_environment_not_found(self, _ip, _throttle, _perm, mock_env_model): + mock_env_model.DoesNotExist = Exception + mock_env_model.objects.select_related.return_value.get.side_effect = Exception("not found") + + request = _build_request("delete", "/public/v1/environments/fake/", self.app) + response = self.view(request, env_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.environments.user_can_access_environment", return_value=True) + @patch("api.views.environments.Environment") + @patch("api.views.environments.can_use_custom_envs", return_value=False) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_delete_custom_env_free_plan_returns_403( + self, _ip, _throttle, _perm, _custom, mock_env_model, _access + ): + env = _make_env(app=self.app, name="my-env", env_type="custom", index=3) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request("delete", f"/public/v1/environments/{env.id}/", self.app) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_can_access_environment", return_value=False) + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_delete_no_env_access_returns_403(self, _ip, _throttle, _perm, mock_env_model, _access): + """User has app access but no EnvironmentKey for this env.""" + env = _make_env(app=self.app, name="test-env", env_type="custom", index=3) + mock_env_model.objects.select_related.return_value.get.return_value = env + + request = _build_request("delete", f"/public/v1/environments/{env.id}/", self.app) + response = self.view(request, env_id=env.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# RBAC tests +# ════════════════════════════════════════════════════════════════════ + + +class TestEnvironmentsAPIRBAC: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.list_view = PublicEnvironmentsView.as_view() + self.detail_view = PublicEnvironmentDetailView.as_view() + self.app = _make_app() + + @patch("api.views.environments.user_has_permission", return_value=False) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_developer_cannot_delete(self, _ip, _throttle, _perm): + env_id = uuid.uuid4() + request = _build_request( + "delete", f"/public/v1/environments/{env_id}/", self.app, role_name="Developer" + ) + response = self.detail_view(request, env_id=env_id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_has_permission", return_value=False) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_developer_cannot_create(self, _ip, _throttle, _perm): + request = _build_request( + "post", "/public/v1/environments/", self.app, + data={"name": "new-env"}, + role_name="Developer", + ) + response = self.list_view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.EnvironmentKey") + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_owner_can_list(self, _ip, _throttle, _perm, mock_env_model, mock_ek_model, mock_serializer): + mock_ek_model.objects.filter.return_value.values_list.return_value = [] + + mock_qs = MagicMock() + mock_qs.order_by.return_value = [] + mock_env_model.objects.filter.return_value = mock_qs + mock_serializer.return_value.data = [] + + request = _build_request("get", "/public/v1/environments/", self.app, role_name="Owner") + response = self.list_view(request) + assert response.status_code == status.HTTP_200_OK + + +# ════════════════════════════════════════════════════════════════════ +# SSE gate tests +# ════════════════════════════════════════════════════════════════════ + + +class TestEnvironmentsAPISSEGate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.list_view = PublicEnvironmentsView.as_view() + self.detail_view = PublicEnvironmentDetailView.as_view() + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_list_rejects_non_sse_app(self, _ip, _throttle, _perm): + app = _make_app(sse_enabled=False) + request = _build_request("get", "/public/v1/environments/", app) + response = self.list_view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_detail_rejects_non_sse_app(self, _ip, _throttle, _perm): + app = _make_app(sse_enabled=False) + env_id = uuid.uuid4() + request = _build_request("get", f"/public/v1/environments/{env_id}/", app) + response = self.detail_view(request, env_id=env_id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# Service account app-membership security tests +# ════════════════════════════════════════════════════════════════════ + + +class TestServiceAccountAppMembership: + """ + Verify that service accounts must be members of the app (via the apps M2M) + to access environment endpoints — not just belong to the same org. + """ + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.list_view = PublicEnvironmentsView.as_view() + self.detail_view = PublicEnvironmentDetailView.as_view() + self.app = _make_app() + + def _build_sa_request(self, method, url, app, data=None, is_app_member=True): + """Build a request authenticated as a ServiceAccount.""" + factory = APIRequestFactory() + user = _make_user() + + if method == "get": + request = factory.get(url) + elif method == "post": + request = factory.post(url, data=data, format="json") + elif method == "delete": + request = factory.delete(url) + else: + raise ValueError(f"Unknown method: {method}") + + sa = Mock() + sa.id = uuid.uuid4() + sa.organisation_id = app.organisation_id + sa.deleted_at = None + + # Wire up app.service_accounts M2M mock + sa_qs = MagicMock() + if is_app_member: + sa_qs.filter.return_value.exists.return_value = True + else: + sa_qs.filter.return_value.exists.return_value = False + app.service_accounts = sa_qs + + auth = _make_auth(app, auth_type="ServiceAccount", service_account=sa) + force_authenticate(request, user=user, token=auth) + return request + + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.EnvironmentKey") + @patch("api.views.environments.Environment") + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_sa_app_member_can_list(self, _ip, _throttle, _perm, mock_env_model, mock_ek_model, mock_serializer): + mock_ek_model.objects.filter.return_value.values_list.return_value = [] + + mock_qs = MagicMock() + mock_qs.order_by.return_value = [] + mock_env_model.objects.filter.return_value = mock_qs + mock_serializer.return_value.data = [] + + request = self._build_sa_request("get", "/public/v1/environments/", self.app, is_app_member=True) + response = self.list_view(request) + assert response.status_code == status.HTTP_200_OK + + @patch("api.views.environments.EnvironmentSerializer") + @patch("api.views.environments.create_environment") + @patch("api.views.environments.can_use_custom_envs", return_value=True) + @patch("api.views.environments.can_add_environment", return_value=True) + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_sa_app_member_can_create( + self, _ip, _throttle, _perm, _quota, _custom, mock_create, mock_serializer + ): + new_env = _make_env(app=self.app, name="test-env", env_type="custom", index=3) + mock_create.return_value = new_env + mock_serializer.return_value.data = {"id": str(new_env.id), "name": "test-env"} + + request = self._build_sa_request( + "post", "/public/v1/environments/", self.app, + data={"name": "test-env"}, is_app_member=True, + ) + response = self.list_view(request) + assert response.status_code == status.HTTP_201_CREATED + mock_create.assert_called_once() + call_args = mock_create.call_args + assert call_args[0] == (self.app, "test-env", "custom") + assert call_args[1]["requesting_user"] is None + assert call_args[1]["requesting_sa"] is not None + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_sa_non_member_cannot_list(self, _ip, _throttle, _perm): + """SA in same org but NOT a member of the app should be rejected.""" + request = self._build_sa_request("get", "/public/v1/environments/", self.app, is_app_member=False) + response = self.list_view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_sa_non_member_cannot_create(self, _ip, _throttle, _perm): + """SA in same org but NOT a member of the app should be rejected.""" + request = self._build_sa_request( + "post", "/public/v1/environments/", self.app, + data={"name": "test-env"}, is_app_member=False, + ) + response = self.list_view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.environments.user_has_permission", return_value=True) + @patch("api.views.environments.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.environments.IsIPAllowed.has_permission", return_value=True) + def test_sa_non_member_cannot_delete(self, _ip, _throttle, _perm): + """SA in same org but NOT a member of the app should be rejected.""" + env_id = uuid.uuid4() + request = self._build_sa_request("delete", f"/public/v1/environments/{env_id}/", self.app, is_app_member=False) + response = self.detail_view(request, env_id=env_id) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/tests/utils/test_environments.py b/backend/tests/utils/test_environments.py new file mode 100644 index 000000000..b8ee37cde --- /dev/null +++ b/backend/tests/utils/test_environments.py @@ -0,0 +1,626 @@ +import json +import pytest +from unittest.mock import MagicMock, patch, PropertyMock + +from api.utils.environments import ( + _ed25519_pk_to_curve25519, + _generate_env_salt, + _generate_env_seed, + _wrap_env_secrets_for_key, + _wrap_for_server, + _wrap_for_service_account, + _wrap_for_user, + create_environment, + get_global_access_members, + get_ssk_service_accounts_for_app, + ENV_NAME_RE, +) + + +# --------------------------------------------------------------------------- +# ENV_NAME_RE +# --------------------------------------------------------------------------- + + +class TestEnvNameRegex: + def test_valid_names(self): + assert ENV_NAME_RE.match("Development") + assert ENV_NAME_RE.match("staging-1") + assert ENV_NAME_RE.match("prod_v2") + assert ENV_NAME_RE.match("a") + assert ENV_NAME_RE.match("A" * 64) + + def test_invalid_names(self): + assert not ENV_NAME_RE.match("") + assert not ENV_NAME_RE.match("has space") + assert not ENV_NAME_RE.match("special!") + assert not ENV_NAME_RE.match("A" * 65) + assert not ENV_NAME_RE.match("env.name") + + +# --------------------------------------------------------------------------- +# Seed / salt generation +# --------------------------------------------------------------------------- + + +@patch("api.utils.environments.random_hex") +def test_generate_env_seed(mock_random_hex): + mock_random_hex.return_value = "aa" * 32 + result = _generate_env_seed() + mock_random_hex.assert_called_once_with(32) + assert result == "aa" * 32 + + +@patch("api.utils.environments.random_hex") +def test_generate_env_salt(mock_random_hex): + mock_random_hex.return_value = "bb" * 32 + result = _generate_env_salt() + mock_random_hex.assert_called_once_with(32) + assert result == "bb" * 32 + + +# --------------------------------------------------------------------------- +# Wrapping helpers +# --------------------------------------------------------------------------- + + +@patch("api.utils.environments.encrypt_asymmetric") +def test_wrap_env_secrets_for_key(mock_encrypt): + mock_encrypt.side_effect = ["wrapped_seed_ct", "wrapped_salt_ct"] + + w_seed, w_salt = _wrap_env_secrets_for_key("seed_hex", "salt_hex", "pubkey_hex") + + assert w_seed == "wrapped_seed_ct" + assert w_salt == "wrapped_salt_ct" + assert mock_encrypt.call_count == 2 + mock_encrypt.assert_any_call("seed_hex", "pubkey_hex") + mock_encrypt.assert_any_call("salt_hex", "pubkey_hex") + + +@patch("api.utils.environments._wrap_env_secrets_for_key") +@patch("api.utils.environments._ed25519_pk_to_curve25519") +def test_wrap_for_user(mock_to_curve, mock_wrap): + mock_to_curve.return_value = "curve25519_pub" + mock_wrap.return_value = ("w_seed", "w_salt") + + member = MagicMock() + member.identity_key = "ed25519_pub_hex" + + result = _wrap_for_user("seed", "salt", member) + + mock_to_curve.assert_called_once_with("ed25519_pub_hex") + mock_wrap.assert_called_once_with("seed", "salt", "curve25519_pub") + assert result == ("w_seed", "w_salt") + + +@patch("api.utils.environments._wrap_env_secrets_for_key") +@patch("api.utils.environments.get_server_keypair") +def test_wrap_for_server(mock_keypair, mock_wrap): + mock_keypair.return_value = (b"\x01" * 32, b"\x02" * 32) + mock_wrap.return_value = ("server_w_seed", "server_w_salt") + + w_seed, w_salt, pk_hex = _wrap_for_server("seed", "salt") + + expected_pk = (b"\x01" * 32).hex() + mock_wrap.assert_called_once_with("seed", "salt", expected_pk) + assert w_seed == "server_w_seed" + assert w_salt == "server_w_salt" + assert pk_hex == expected_pk + + +@patch("api.utils.environments._wrap_env_secrets_for_key") +@patch("api.utils.environments._ed25519_pk_to_curve25519") +@patch("api.utils.environments.decrypt_asymmetric") +@patch("api.utils.environments.get_server_keypair") +def test_wrap_for_service_account_ssk_enabled( + mock_keypair, mock_decrypt, mock_to_curve, mock_wrap +): + mock_keypair.return_value = (b"\x01" * 32, b"\x02" * 32) + keyring = {"publicKey": "sa_ed25519_pub", "privateKey": "sa_ed25519_priv"} + mock_decrypt.return_value = json.dumps(keyring) + mock_to_curve.return_value = "sa_curve25519_pub" + mock_wrap.return_value = ("sa_w_seed", "sa_w_salt") + + sa = MagicMock() + sa.server_wrapped_keyring = "encrypted_keyring" + + result = _wrap_for_service_account("seed", "salt", sa) + + assert result == ("sa_w_seed", "sa_w_salt") + mock_to_curve.assert_called_once_with("sa_ed25519_pub") + mock_wrap.assert_called_once_with("seed", "salt", "sa_curve25519_pub") + + +def test_wrap_for_service_account_ssk_not_enabled(): + sa = MagicMock() + sa.server_wrapped_keyring = None + + result = _wrap_for_service_account("seed", "salt", sa) + assert result is None + + +# --------------------------------------------------------------------------- +# Ed25519 → Curve25519 conversion +# --------------------------------------------------------------------------- + + +@patch("nacl.bindings.crypto_sign_ed25519_pk_to_curve25519") +def test_ed25519_pk_to_curve25519(mock_convert): + mock_convert.return_value = b"\xab" * 32 + result = _ed25519_pk_to_curve25519("cd" * 32) + mock_convert.assert_called_once_with(bytes.fromhex("cd" * 32)) + assert result == "ab" * 32 + + +# --------------------------------------------------------------------------- +# Query helpers +# --------------------------------------------------------------------------- + + +@patch("api.utils.environments.OrganisationMember") +@patch("api.utils.environments.Role") +def test_get_global_access_members(mock_role_model, mock_member_model): + org = MagicMock() + + mock_roles_qs = MagicMock() + mock_role_model.objects.filter.return_value = mock_roles_qs + + member1 = MagicMock() + member2 = MagicMock() + mock_members_qs = MagicMock() + mock_members_qs.select_related.return_value = [member1, member2] + mock_member_model.objects.filter.return_value = mock_members_qs + + result = get_global_access_members(org) + + mock_role_model.objects.filter.assert_called_once() + mock_member_model.objects.filter.assert_called_once() + assert result == [member1, member2] + + +def test_get_ssk_service_accounts_for_app(): + app = MagicMock() + sa1 = MagicMock() + sa2 = MagicMock() + app.service_accounts.filter.return_value = MagicMock() + app.service_accounts.filter.return_value.__iter__ = MagicMock( + return_value=iter([sa1, sa2]) + ) + + # Calling list() on the mock queryset + app.service_accounts.filter.return_value = [sa1, sa2] + result = get_ssk_service_accounts_for_app(app) + + app.service_accounts.filter.assert_called_once_with( + server_wrapped_keyring__isnull=False, + deleted_at=None, + ) + assert result == [sa1, sa2] + + +# --------------------------------------------------------------------------- +# create_environment (integration-style with mocks) +# --------------------------------------------------------------------------- + + +class TestCreateEnvironment: + @patch("api.utils.environments.transaction") + @patch("api.utils.environments.ServerEnvironmentKey") + @patch("api.utils.environments.EnvironmentKey") + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_ssk_service_accounts_for_app") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments._wrap_for_user") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_creates_environment_with_all_records( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_user, + mock_wrap_server, + mock_get_members, + mock_get_ssk_sa, + mock_env_model, + mock_env_key_model, + mock_server_key_model, + mock_transaction, + ): + # Setup + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("env_pub_hex", "env_priv_hex") + mock_wrap_server.return_value = ("srv_w_seed", "srv_w_salt", "srv_pk") + + owner = MagicMock() + owner.id = "owner-id" + owner.identity_key = "owner_ed25519" + owner.role.name = "Owner" + + admin = MagicMock() + admin.id = "admin-id" + admin.identity_key = "admin_ed25519" + admin.role.name = "Admin" + + mock_get_members.return_value = [owner, admin] + mock_get_ssk_sa.return_value = [] + + mock_wrap_user.side_effect = [ + ("owner_w_seed", "owner_w_salt"), + ("admin_w_seed", "admin_w_salt"), + ] + + app = MagicMock() + app.organisation = MagicMock() + + mock_env_instance = MagicMock() + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.filter.return_value.aggregate.return_value = { + "index__max": 2 + } + mock_env_model.objects.create.return_value = mock_env_instance + + # Execute + result = create_environment(app, "test-env", "custom") + + # Verify environment created + mock_env_model.objects.create.assert_called_once() + create_kwargs = mock_env_model.objects.create.call_args[1] + assert create_kwargs["name"] == "test-env" + assert create_kwargs["env_type"] == "custom" + assert create_kwargs["identity_key"] == "env_pub_hex" + assert create_kwargs["wrapped_seed"] == "owner_w_seed" + assert create_kwargs["wrapped_salt"] == "owner_w_salt" + assert create_kwargs["index"] == 3 # max_index(2) + 1 + + # Verify EnvironmentKeys bulk created (owner + admin) + mock_env_key_model.objects.bulk_create.assert_called_once() + env_keys = mock_env_key_model.objects.bulk_create.call_args[0][0] + assert len(env_keys) == 2 + + # Verify ServerEnvironmentKey created + mock_server_key_model.objects.create.assert_called_once_with( + environment=mock_env_instance, + identity_key="env_pub_hex", + wrapped_seed="srv_w_seed", + wrapped_salt="srv_w_salt", + ) + + assert result == mock_env_instance + + def test_rejects_invalid_name(self): + app = MagicMock() + with pytest.raises(ValueError, match="invalid"): + create_environment(app, "has space!") + + @patch("api.utils.environments.Environment") + def test_rejects_duplicate_name(self, mock_env_model): + mock_env_model.objects.filter.return_value.exists.return_value = True + + app = MagicMock() + with pytest.raises(ValueError, match="already exists"): + create_environment(app, "staging") + + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_raises_when_no_members_with_identity_keys( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_server, + mock_get_members, + mock_env_model, + ): + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("pub", "priv") + mock_wrap_server.return_value = ("ws", "wsa", "pk") + mock_get_members.return_value = [] + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.filter.return_value.aggregate.return_value = { + "index__max": None + } + + app = MagicMock() + with pytest.raises(ValueError, match="no global-access members"): + create_environment(app, "myenv") + + @patch("api.utils.environments.transaction") + @patch("api.utils.environments.ServerEnvironmentKey") + @patch("api.utils.environments.EnvironmentKey") + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_ssk_service_accounts_for_app") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_service_account") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments._wrap_for_user") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_wraps_for_ssk_service_accounts( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_user, + mock_wrap_server, + mock_wrap_sa, + mock_get_members, + mock_get_ssk_sa, + mock_env_model, + mock_env_key_model, + mock_server_key_model, + mock_transaction, + ): + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("env_pub", "env_priv") + mock_wrap_server.return_value = ("srv_ws", "srv_wsa", "srv_pk") + + owner = MagicMock() + owner.id = "owner-id" + owner.identity_key = "owner_key" + owner.role.name = "Owner" + mock_get_members.return_value = [owner] + mock_wrap_user.return_value = ("o_ws", "o_wsa") + + sa = MagicMock() + sa.id = "sa-id" + mock_get_ssk_sa.return_value = [sa] + mock_wrap_sa.return_value = ("sa_ws", "sa_wsa") + + mock_env_instance = MagicMock() + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.filter.return_value.aggregate.return_value = { + "index__max": None + } + mock_env_model.objects.create.return_value = mock_env_instance + + app = MagicMock() + + create_environment(app, "myenv") + + # Should have 2 env keys: 1 for owner + 1 for SA + env_keys = mock_env_key_model.objects.bulk_create.call_args[0][0] + assert len(env_keys) == 2 + + @patch("api.utils.environments.transaction") + @patch("api.utils.environments.ServerEnvironmentKey") + @patch("api.utils.environments.EnvironmentKey") + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_ssk_service_accounts_for_app") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments._wrap_for_user") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_requesting_user_gets_env_key_when_not_global( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_user, + mock_wrap_server, + mock_get_members, + mock_get_ssk_sa, + mock_env_model, + mock_env_key_model, + mock_server_key_model, + mock_transaction, + ): + """A non-global-access user who creates the env should get an EnvironmentKey.""" + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("env_pub", "env_priv") + mock_wrap_server.return_value = ("srv_ws", "srv_wsa", "srv_pk") + mock_get_ssk_sa.return_value = [] + + owner = MagicMock() + owner.id = "owner-id" + owner.identity_key = "owner_key" + owner.role.name = "Owner" + mock_get_members.return_value = [owner] + + # requesting_user is NOT in global members + requester = MagicMock() + requester.id = "requester-id" + requester.identity_key = "requester_key" + + mock_wrap_user.side_effect = [ + ("owner_ws", "owner_wsa"), + ("req_ws", "req_wsa"), + ] + + mock_env_instance = MagicMock() + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.filter.return_value.aggregate.return_value = { + "index__max": None + } + mock_env_model.objects.create.return_value = mock_env_instance + + app = MagicMock() + create_environment(app, "myenv", requesting_user=requester) + + # Should have 2 env keys: owner + requester + env_keys = mock_env_key_model.objects.bulk_create.call_args[0][0] + assert len(env_keys) == 2 + + @patch("api.utils.environments.transaction") + @patch("api.utils.environments.ServerEnvironmentKey") + @patch("api.utils.environments.EnvironmentKey") + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_ssk_service_accounts_for_app") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments._wrap_for_user") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_requesting_user_not_duplicated_when_global( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_user, + mock_wrap_server, + mock_get_members, + mock_get_ssk_sa, + mock_env_model, + mock_env_key_model, + mock_server_key_model, + mock_transaction, + ): + """A global-access user who creates the env should NOT get a duplicate key.""" + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("env_pub", "env_priv") + mock_wrap_server.return_value = ("srv_ws", "srv_wsa", "srv_pk") + mock_get_ssk_sa.return_value = [] + + owner = MagicMock() + owner.id = "owner-id" + owner.identity_key = "owner_key" + owner.role.name = "Owner" + mock_get_members.return_value = [owner] + + mock_wrap_user.return_value = ("owner_ws", "owner_wsa") + + mock_env_instance = MagicMock() + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.filter.return_value.aggregate.return_value = { + "index__max": None + } + mock_env_model.objects.create.return_value = mock_env_instance + + app = MagicMock() + # requesting_user IS the owner (already in global members) + create_environment(app, "myenv", requesting_user=owner) + + # Should still only have 1 env key (no duplicate) + env_keys = mock_env_key_model.objects.bulk_create.call_args[0][0] + assert len(env_keys) == 1 + + @patch("api.utils.environments._ed25519_pk_to_curve25519") + @patch("api.utils.environments._wrap_env_secrets_for_key") + @patch("api.utils.environments.transaction") + @patch("api.utils.environments.ServerEnvironmentKey") + @patch("api.utils.environments.EnvironmentKey") + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_ssk_service_accounts_for_app") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments._wrap_for_user") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_requesting_sa_gets_env_key_when_not_ssk( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_user, + mock_wrap_server, + mock_get_members, + mock_get_ssk_sa, + mock_env_model, + mock_env_key_model, + mock_server_key_model, + mock_transaction, + mock_wrap_key, + mock_to_curve, + ): + """A non-SSK SA that creates the env should get an EnvironmentKey via identity_key.""" + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("env_pub", "env_priv") + mock_wrap_server.return_value = ("srv_ws", "srv_wsa", "srv_pk") + mock_get_ssk_sa.return_value = [] # SA not in SSK list + + owner = MagicMock() + owner.id = "owner-id" + owner.identity_key = "owner_key" + owner.role.name = "Owner" + mock_get_members.return_value = [owner] + mock_wrap_user.return_value = ("owner_ws", "owner_wsa") + + sa = MagicMock() + sa.id = "sa-id" + sa.identity_key = "sa_ed25519_pub" + + mock_to_curve.return_value = "sa_curve25519_pub" + mock_wrap_key.return_value = ("sa_ws", "sa_wsa") + + mock_env_instance = MagicMock() + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.filter.return_value.aggregate.return_value = { + "index__max": None + } + mock_env_model.objects.create.return_value = mock_env_instance + + app = MagicMock() + create_environment(app, "myenv", requesting_sa=sa) + + # Should have 2 env keys: owner + requesting SA + env_keys = mock_env_key_model.objects.bulk_create.call_args[0][0] + assert len(env_keys) == 2 + + @patch("api.utils.environments.transaction") + @patch("api.utils.environments.ServerEnvironmentKey") + @patch("api.utils.environments.EnvironmentKey") + @patch("api.utils.environments.Environment") + @patch("api.utils.environments.get_ssk_service_accounts_for_app") + @patch("api.utils.environments.get_global_access_members") + @patch("api.utils.environments._wrap_for_server") + @patch("api.utils.environments._wrap_for_user") + @patch("api.utils.environments.env_keypair") + @patch("api.utils.environments._generate_env_salt") + @patch("api.utils.environments._generate_env_seed") + def test_env_type_indices( + self, + mock_seed, + mock_salt, + mock_keypair, + mock_wrap_user, + mock_wrap_server, + mock_get_members, + mock_get_ssk_sa, + mock_env_model, + mock_env_key_model, + mock_server_key_model, + mock_transaction, + ): + """Test that dev/staging/prod get indices 0/1/2.""" + mock_seed.return_value = "aa" * 32 + mock_salt.return_value = "bb" * 32 + mock_keypair.return_value = ("pub", "priv") + mock_wrap_server.return_value = ("ws", "wsa", "pk") + mock_get_ssk_sa.return_value = [] + + owner = MagicMock() + owner.id = "owner-id" + owner.identity_key = "key" + owner.role.name = "Owner" + mock_get_members.return_value = [owner] + mock_wrap_user.return_value = ("ws", "wsa") + + app = MagicMock() + + for env_type, expected_index in [("dev", 0), ("staging", 1), ("prod", 2)]: + mock_env_model.objects.filter.return_value.exists.return_value = False + mock_env_model.objects.create.return_value = MagicMock() + + create_environment(app, f"env-{env_type}", env_type) + + create_kwargs = mock_env_model.objects.create.call_args[1] + assert create_kwargs["index"] == expected_index, ( + f"Expected index {expected_index} for {env_type}" + ) From 64d6348818c9003f2bcc72d430d6d0f7626e42df Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 9 Mar 2026 23:17:39 +0530 Subject: [PATCH 02/20] fix: use cache-and-network policy on app access pages Signed-off-by: rohan --- .../[app]/access/members/_components/ManageUserAccessDialog.tsx | 2 ++ frontend/app/[team]/apps/[app]/access/members/page.tsx | 1 + .../service-accounts/_components/ManageAccountAccessDialog.tsx | 2 ++ frontend/app/[team]/apps/[app]/access/service-accounts/page.tsx | 1 + 4 files changed, 6 insertions(+) diff --git a/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx b/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx index 7b0bac78f..2d0762b48 100644 --- a/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx @@ -50,6 +50,7 @@ export const ManageUserAccessDialog = ({ variables: { appId: appId, }, + fetchPolicy: 'cache-and-network', }) // Get the environemnts that the member has access to @@ -58,6 +59,7 @@ export const ManageUserAccessDialog = ({ appId: appId, memberId: member.id, }, + fetchPolicy: 'cache-and-network', }) const envScope: Array> = useMemo(() => { diff --git a/frontend/app/[team]/apps/[app]/access/members/page.tsx b/frontend/app/[team]/apps/[app]/access/members/page.tsx index 19f4e067c..21a1fb9c6 100644 --- a/frontend/app/[team]/apps/[app]/access/members/page.tsx +++ b/frontend/app/[team]/apps/[app]/access/members/page.tsx @@ -40,6 +40,7 @@ export default function Members({ params }: { params: { team: string; app: strin const { data, loading } = useQuery(GetAppMembers, { variables: { appId: params.app }, skip: !userCanReadAppMembers, + fetchPolicy: 'cache-and-network', }) const { data: session } = useSession() diff --git a/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx b/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx index 969848aa0..ad8f9dc79 100644 --- a/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx @@ -50,6 +50,7 @@ export const ManageAccountAccessDialog = ({ variables: { appId: appId, }, + fetchPolicy: 'cache-and-network', }) // Get the environemnts that the account has access to @@ -59,6 +60,7 @@ export const ManageAccountAccessDialog = ({ memberId: account.id, memberType: MemberType.Service, }, + fetchPolicy: 'cache-and-network', }) const [getEnvKey] = useLazyQuery(GetEnvironmentKey) diff --git a/frontend/app/[team]/apps/[app]/access/service-accounts/page.tsx b/frontend/app/[team]/apps/[app]/access/service-accounts/page.tsx index 431e5a366..f9f8604c6 100644 --- a/frontend/app/[team]/apps/[app]/access/service-accounts/page.tsx +++ b/frontend/app/[team]/apps/[app]/access/service-accounts/page.tsx @@ -39,6 +39,7 @@ export default function ServiceAccounts({ params }: { params: { team: string; ap const { data, loading } = useQuery(GetAppServiceAccounts, { variables: { appId: params.app }, skip: !userCanReadAppSA, + fetchPolicy: 'cache-and-network', }) const filteredAccounts = data?.appServiceAccounts From f71eed0e357897527755dd31b0f659d3ed442986 Mon Sep 17 00:00:00 2001 From: rohan Date: Tue, 10 Mar 2026 00:37:17 +0530 Subject: [PATCH 03/20] feat: add apps api Signed-off-by: rohan --- backend/api/auth.py | 39 +- backend/api/serializers.py | 7 + backend/api/throttling.py | 9 + backend/api/views/apps.py | 352 +++++++++++ backend/backend/urls.py | 3 + backend/tests/api/views/test_apps_api.py | 765 +++++++++++++++++++++++ 6 files changed, 1170 insertions(+), 5 deletions(-) create mode 100644 backend/api/views/apps.py create mode 100644 backend/tests/api/views/test_apps_api.py diff --git a/backend/api/auth.py b/backend/api/auth.py index 93d0164b4..b04ba25bf 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -154,9 +154,32 @@ def authenticate(self, request): except Environment.DoesNotExist: raise exceptions.NotFound("Environment not found") else: - raise exceptions.AuthenticationFailed( - "Missing app_id parameter" - ) + # Check URL kwargs for app_id + # (used by detail endpoints like /apps//) + 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 @@ -175,7 +198,10 @@ def authenticate(self, request): auth["org_member"] = org_member user = org_member.user - if env: + 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" @@ -211,7 +237,10 @@ def authenticate(self, request): auth["service_account"] = service_account auth["service_account_token"] = service_token - if env: + 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 ): diff --git a/backend/api/serializers.py b/backend/api/serializers.py index fd8173c73..4932f3e50 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -8,6 +8,7 @@ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from .models import ( + App, CustomUser, Environment, EnvironmentKey, @@ -217,6 +218,12 @@ def get_type(self, obj): return "static" +class AppSerializer(serializers.ModelSerializer): + class Meta: + model = App + fields = ["id", "name", "description", "sse_enabled", "created_at", "updated_at"] + + class EnvironmentSerializer(serializers.ModelSerializer): class Meta: model = Environment diff --git a/backend/api/throttling.py b/backend/api/throttling.py index 71766cb3e..0ae7ec13f 100644 --- a/backend/api/throttling.py +++ b/backend/api/throttling.py @@ -52,6 +52,15 @@ def allow_request(self, request, view): new_rate = self.get_rate_for_plan(plan) except AttributeError: pass + else: + # Org-only mode fallback (e.g. apps CRUD API) + org = request.auth.get("organisation") + if org: + try: + plan = org.plan + new_rate = self.get_rate_for_plan(plan) + except AttributeError: + pass # Update the throttle configuration for this specific request self.rate = new_rate diff --git a/backend/api/views/apps.py b/backend/api/views/apps.py new file mode 100644 index 000000000..f955b320d --- /dev/null +++ b/backend/api/views/apps.py @@ -0,0 +1,352 @@ +import logging + +from api.auth import PhaseTokenAuthentication +from api.models import App, OrganisationMember, Role +from api.serializers import AppSerializer +from api.utils.access.permissions import user_has_permission +from api.utils.crypto import ( + encrypt_raw, + env_keypair, + get_server_keypair, + random_hex, + split_secret_hex, + wrap_share_hex, +) +from api.utils.environments import create_environment +from api.utils.rest import METHOD_TO_ACTION +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.response import Response +from rest_framework import status +from djangorestframework_camel_case.render import CamelCaseJSONRenderer +from django.conf import settings +from django.db import transaction +from django.db.models import Q + +logger = logging.getLogger(__name__) + +CLOUD_HOSTED = settings.APP_HOST == "cloud" + + +class PublicAppsView(APIView): + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + """Resolve the organisation from the request auth context.""" + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + 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}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission( + account, action, "Apps", org, False, is_sa + ): + raise PermissionDenied( + f"You don't have permission to {action} apps." + ) + + def get(self, request, *args, **kwargs): + org = self._get_org(request) + + org_apps = App.objects.filter( + organisation=org, + is_deleted=False, + sse_enabled=True, + ) + + if request.auth["auth_type"] == "User": + org_member = request.auth["org_member"] + accessible_apps = org_apps.filter(members=org_member) + elif request.auth["auth_type"] == "ServiceAccount": + sa = request.auth["service_account"] + accessible_apps = org_apps.filter(service_accounts=sa) + else: + accessible_apps = App.objects.none() + + serializer = AppSerializer( + accessible_apps.order_by("-created_at"), many=True + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + 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, + ) + + 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, + ) + + # --- Validate optional environments list --- + custom_envs = request.data.get("environments", None) + if custom_envs is not None: + if not isinstance(custom_envs, list): + return Response( + {"error": "'environments' must be a list of environment names."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(custom_envs) == 0: + return Response( + {"error": "'environments' must not be empty."}, + status=status.HTTP_400_BAD_REQUEST, + ) + for env_name in custom_envs: + if not isinstance(env_name, str) or not env_name.strip(): + return Response( + {"error": "Each environment name must be a non-empty string."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check for duplicates (case-insensitive) + seen = set() + for env_name in custom_envs: + lower = env_name.strip().lower() + if lower in seen: + return Response( + {"error": f"Duplicate environment name: '{env_name.strip()}'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + seen.add(lower) + if not can_use_custom_envs(org): + return Response( + {"error": "Custom environments are not available on the Free plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # --- Check quota --- + if not can_add_app(org): + return Response( + {"error": "App quota exceeded for this organisation's plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # --- Generate cryptographic material (server-side, SSE) --- + app_seed = random_hex(32) + app_token = random_hex(32) + wrap_key = random_hex(32) + + identity_key_pub, identity_key_priv = env_keypair(app_seed) + + _share0, share1 = split_secret_hex(identity_key_priv) + wrapped_key_share = wrap_share_hex(share1, wrap_key) + + _server_pk, server_sk = get_server_keypair() + encrypted_app_seed = bytes(encrypt_raw(app_seed, server_sk)).hex() + + # --- Determine requesting account --- + requesting_user = None + requesting_sa = None + if request.auth["auth_type"] == "User": + requesting_user = request.auth["org_member"] + elif request.auth["auth_type"] == "ServiceAccount": + requesting_sa = request.auth["service_account"] + + # --- Create app + members + default environments --- + try: + with transaction.atomic(): + app = App.objects.create( + organisation=org, + name=name, + description=description, + identity_key=identity_key_pub, + app_version=1, + app_token=app_token, + app_seed=encrypted_app_seed, + wrapped_key_share=wrapped_key_share, + sse_enabled=True, + ) + + # Add requesting account to app members + if requesting_user: + requesting_user.apps.add(app) + if requesting_sa: + requesting_sa.apps.add(app) + + # Add all org owners/admins to app members + admin_roles = Role.objects.filter( + Q(organisation=org) + & (Q(name__iexact="owner") | Q(name__iexact="admin")) + ) + org_admins = OrganisationMember.objects.filter( + organisation=org, + role__in=admin_roles, + deleted_at=None, + ) + for admin in org_admins: + admin.apps.add(app) + + # Create environments + if custom_envs is not None: + for env_name in custom_envs: + create_environment( + app, + env_name.strip(), + "custom", + requesting_user=requesting_user, + requesting_sa=requesting_sa, + ) + else: + for env_name, env_type in [ + ("Development", "dev"), + ("Staging", "staging"), + ("Production", "prod"), + ]: + create_environment( + app, + env_name, + env_type, + requesting_user=requesting_user, + requesting_sa=requesting_sa, + ) + + except ValueError as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = AppSerializer(app) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class PublicAppDetailView(APIView): + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + app = request.auth.get("app") + if not app: + raise PermissionDenied("Could not resolve app from request.") + + if not app.sse_enabled: + raise PermissionDenied("SSE is not enabled for this App.") + + action = METHOD_TO_ACTION.get(request.method) + if not action: + raise PermissionDenied(f"Unsupported HTTP method: {request.method}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + organisation = app.organisation + if not user_has_permission( + account, action, "Apps", organisation, False, is_sa + ): + raise PermissionDenied( + f"You don't have permission to {action} apps." + ) + + def get(self, request, app_id, *args, **kwargs): + app = request.auth["app"] + serializer = AppSerializer(app) + return Response(serializer.data, status=status.HTTP_200_OK) + + def put(self, request, app_id, *args, **kwargs): + app = request.auth["app"] + + name = request.data.get("name") + description = request.data.get("description") + + if name is None and description is None: + return Response( + {"error": "At least one of 'name' or 'description' must be provided."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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 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, + ) + app.description = description + + app.save() + + serializer = AppSerializer(app) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, app_id, *args, **kwargs): + app = request.auth["app"] + + if CLOUD_HOSTED: + from backend.api.kv import delete as kv_delete, purge as kv_purge + + deleted = kv_delete(app.app_token) + purged = kv_purge( + f"phApp:v{app.app_version}:{app.identity_key}/{app.app_token}" + ) + if not deleted or not purged: + return Response( + {"error": "Failed to delete app keys from CDN. Please try again."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + app.wrapped_key_share = "" + app.save() + app.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 3de1893be..16d2bb4bf 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -4,6 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from api.views.lockbox import LockboxView from api.views.graphql import PrivateGraphQLView +from api.views.apps import PublicAppsView, PublicAppDetailView from api.views.environments import PublicEnvironmentsView, PublicEnvironmentDetailView from api.views.secrets import E2EESecretsView, PublicSecretsView from api.views.auth import ( @@ -45,6 +46,8 @@ public_urls = [ path("public/", root_endpoint), path("public/v1/secrets/", PublicSecretsView.as_view()), + path("public/v1/apps/", PublicAppsView.as_view()), + path("public/v1/apps//", PublicAppDetailView.as_view()), path("public/v1/environments/", PublicEnvironmentsView.as_view()), path("public/v1/environments//", PublicEnvironmentDetailView.as_view()), path( diff --git a/backend/tests/api/views/test_apps_api.py b/backend/tests/api/views/test_apps_api.py new file mode 100644 index 000000000..145028ea5 --- /dev/null +++ b/backend/tests/api/views/test_apps_api.py @@ -0,0 +1,765 @@ +import uuid +import pytest +from unittest.mock import Mock, MagicMock, patch, PropertyMock +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework import status + +from api.views.apps import PublicAppsView, PublicAppDetailView + + +# ──────────────────────────────────────────────────────────────────── +# Shared test helpers +# ──────────────────────────────────────────────────────────────────── + + +def _make_org(plan="PR", org_id=None): + org = Mock() + org.id = org_id or uuid.uuid4() + org.plan = plan + org.organisation_id = org.id + return org + + +def _make_app(org=None, sse_enabled=True, name="my-app", app_id=None): + if org is None: + org = _make_org() + app = Mock(spec=[ + "id", "name", "description", "sse_enabled", "organisation", "organisation_id", + "identity_key", "app_version", "app_token", "app_seed", "wrapped_key_share", + "is_deleted", "created_at", "updated_at", "save", "delete", + "members", "service_accounts", + ]) + app.id = app_id or uuid.uuid4() + app.name = name + app.description = None + app.sse_enabled = sse_enabled + app.organisation = org + app.organisation_id = org.id + app.identity_key = "deadbeef" * 8 + app.app_version = 1 + app.app_token = "aabbccdd" * 8 + app.app_seed = "encrypted_seed" + app.wrapped_key_share = "wrapped_share" + app.is_deleted = False + app.created_at = "2024-01-01T00:00:00Z" + app.updated_at = "2024-01-01T00:00:00Z" + return app + + +def _make_user(): + user = Mock() + user.userId = uuid.uuid4() + user.id = user.userId + user.is_authenticated = True + user.is_active = True + return user + + +def _make_org_member(org=None, role_name="Owner"): + member = Mock() + member.id = uuid.uuid4() + member.user = _make_user() + member.organisation = org or _make_org() + member.deleted_at = None + member.role = Mock() + member.role.name = role_name + member.apps = Mock() + return member + + +def _make_auth_org_only(org, auth_type="User", org_member=None, service_account=None): + return { + "token": "Bearer User test_token", + "auth_type": auth_type, + "app": None, + "environment": None, + "org_member": org_member, + "service_token": None, + "service_account": service_account, + "service_account_token": None, + "organisation": org, + "org_only": True, + } + + +def _make_auth_app(app, auth_type="User", org_member=None, service_account=None): + return { + "token": "Bearer User test_token", + "auth_type": auth_type, + "app": app, + "environment": None, + "org_member": org_member, + "service_token": None, + "service_account": service_account, + "service_account_token": None, + } + + +def _build_list_request(method, url, org, data=None, auth_type="User", role_name="Owner"): + """Build a request in org-only mode for list/create endpoints.""" + factory = APIRequestFactory() + + if method == "get": + request = factory.get(url) + elif method == "post": + request = factory.post(url, data=data, format="json") + else: + raise ValueError(f"Unknown method: {method}") + + org_member = _make_org_member(org=org, role_name=role_name) + + sa = None + if auth_type == "ServiceAccount": + sa = Mock() + sa.id = uuid.uuid4() + sa.organisation = org + sa.organisation_id = org.id + sa.apps = Mock() + + auth = _make_auth_org_only( + org, + auth_type=auth_type, + org_member=org_member if auth_type == "User" else None, + service_account=sa, + ) + + force_authenticate(request, user=org_member.user, token=auth) + return request + + +def _build_detail_request(method, url, app, data=None, auth_type="User", role_name="Owner"): + """Build a request in app mode for detail endpoints.""" + factory = APIRequestFactory() + + if method == "get": + request = factory.get(url) + elif method == "put": + request = factory.put(url, data=data, format="json") + elif method == "delete": + request = factory.delete(url) + else: + raise ValueError(f"Unknown method: {method}") + + org_member = _make_org_member(org=app.organisation, role_name=role_name) + + sa = None + if auth_type == "ServiceAccount": + sa = Mock() + sa.id = uuid.uuid4() + sa.organisation = app.organisation + sa.organisation_id = app.organisation_id + + auth = _make_auth_app( + app, + auth_type=auth_type, + org_member=org_member if auth_type == "User" else None, + service_account=sa, + ) + + force_authenticate(request, user=org_member.user, token=auth) + return request + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicAppsView — List +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicAppsViewList: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicAppsView.as_view() + self.org = _make_org() + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.App") + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_list_apps_success(self, _ip, _throttle, _perm, mock_app_model, mock_serializer): + mock_org_qs = MagicMock() + mock_filtered = MagicMock() + mock_filtered.order_by.return_value = [Mock(), Mock()] + mock_org_qs.filter.return_value = mock_filtered + mock_app_model.objects.filter.return_value = mock_org_qs + + mock_serializer.return_value.data = [ + {"id": "1", "name": "app-1"}, + {"id": "2", "name": "app-2"}, + ] + + request = _build_list_request("get", "/public/v1/apps/", self.org) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.App") + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_list_apps_empty(self, _ip, _throttle, _perm, mock_app_model, mock_serializer): + mock_org_qs = MagicMock() + mock_filtered = MagicMock() + mock_filtered.order_by.return_value = [] + mock_org_qs.filter.return_value = mock_filtered + mock_app_model.objects.filter.return_value = mock_org_qs + + mock_serializer.return_value.data = [] + + request = _build_list_request("get", "/public/v1/apps/", self.org) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 0 + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_list_no_permission_returns_403(self, _ip, _throttle, _perm): + request = _build_list_request("get", "/public/v1/apps/", self.org, role_name="Developer") + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicAppsView — Create +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicAppsViewCreate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + settings.APP_HOST = "self" + self.view = PublicAppsView.as_view() + self.org = _make_org() + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.create_environment") + @patch("api.views.apps.OrganisationMember") + @patch("api.views.apps.Role") + @patch("api.views.apps.App") + @patch("api.views.apps.transaction") + @patch("api.views.apps.encrypt_raw", return_value=bytearray(b"\x00" * 104)) + @patch("api.views.apps.get_server_keypair", return_value=(b"\x00" * 32, b"\x01" * 32)) + @patch("api.views.apps.wrap_share_hex", return_value="wrapped_share") + @patch("api.views.apps.split_secret_hex", return_value=("share0", "share1")) + @patch("api.views.apps.env_keypair", return_value=("pub_hex", "priv_hex")) + @patch("api.views.apps.random_hex", return_value="aa" * 32) + @patch("api.views.apps.can_add_app", return_value=True) + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_app_success( + self, _ip, _throttle, _perm, _quota, + _random, _keypair, _split, _wrap, _server_kp, _encrypt, + _txn, mock_app_model, mock_role, mock_org_member, mock_create_env, mock_serializer, + ): + new_app = _make_app(org=self.org, name="test-app") + mock_app_model.objects.create.return_value = new_app + + # Mock admin query to return empty (no admins to add) + mock_role.objects.filter.return_value = [] + mock_org_member.objects.filter.return_value = [] + + mock_serializer.return_value.data = {"id": str(new_app.id), "name": "test-app"} + + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "test-app"}, + ) + response = self.view(request) + + assert response.status_code == status.HTTP_201_CREATED + mock_app_model.objects.create.assert_called_once() + # Verify create_environment was called 3 times (dev, staging, prod) + assert mock_create_env.call_count == 3 + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_missing_name_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_blank_name_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": " "}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_name_too_long_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "x" * 65}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.apps.can_add_app", return_value=False) + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_quota_exceeded_returns_403(self, _ip, _throttle, _perm, _quota): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "new-app"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_no_permission_returns_403(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "new-app"}, + role_name="Developer", + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # --- Custom environments field --- + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_envs_not_a_list_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "my-app", "environments": "not-a-list"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == "'environments' must be a list of environment names." + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_envs_empty_list_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "my-app", "environments": []}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == "'environments' must not be empty." + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_envs_non_string_entry_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "my-app", "environments": ["valid", 123]}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == "Each environment name must be a non-empty string." + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_envs_blank_entry_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "my-app", "environments": ["valid", " "]}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == "Each environment name must be a non-empty string." + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_envs_duplicate_names_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "my-app", "environments": ["staging", "Staging"]}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Duplicate environment name" in response.data["error"] + + @patch("api.views.apps.can_use_custom_envs", return_value=False) + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_custom_envs_free_plan_returns_403(self, _ip, _throttle, _perm, _custom): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "my-app", "environments": ["test", "live"]}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "Free plan" in response.data["error"] + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.create_environment") + @patch("api.views.apps.OrganisationMember") + @patch("api.views.apps.Role") + @patch("api.views.apps.App") + @patch("api.views.apps.transaction") + @patch("api.views.apps.encrypt_raw", return_value=bytearray(b"\x00" * 104)) + @patch("api.views.apps.get_server_keypair", return_value=(b"\x00" * 32, b"\x01" * 32)) + @patch("api.views.apps.wrap_share_hex", return_value="wrapped_share") + @patch("api.views.apps.split_secret_hex", return_value=("share0", "share1")) + @patch("api.views.apps.env_keypair", return_value=("pub_hex", "priv_hex")) + @patch("api.views.apps.random_hex", return_value="aa" * 32) + @patch("api.views.apps.can_add_app", return_value=True) + @patch("api.views.apps.can_use_custom_envs", return_value=True) + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_create_app_with_custom_envs( + self, _ip, _throttle, _perm, _custom, _quota, + _random, _keypair, _split, _wrap, _server_kp, _encrypt, + _txn, mock_app_model, mock_role, mock_org_member, mock_create_env, mock_serializer, + ): + new_app = _make_app(org=self.org, name="test-app") + mock_app_model.objects.create.return_value = new_app + mock_role.objects.filter.return_value = [] + mock_org_member.objects.filter.return_value = [] + mock_serializer.return_value.data = {"id": str(new_app.id), "name": "test-app"} + + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "test-app", "environments": ["test", "live"]}, + ) + response = self.view(request) + + assert response.status_code == status.HTTP_201_CREATED + assert mock_create_env.call_count == 2 + # Verify both calls used "custom" env_type + for call in mock_create_env.call_args_list: + assert call[0][2] == "custom" + # Verify env names + env_names = [call[0][1] for call in mock_create_env.call_args_list] + assert env_names == ["test", "live"] + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicAppDetailView — Get +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicAppDetailViewGet: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicAppDetailView.as_view() + self.org = _make_org() + self.app = _make_app(org=self.org) + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_get_app_success(self, _ip, _throttle, _perm, mock_serializer): + mock_serializer.return_value.data = {"id": str(self.app.id), "name": "my-app"} + + request = _build_detail_request("get", f"/public/v1/apps/{self.app.id}/", self.app) + response = self.view(request, app_id=self.app.id) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "my-app" + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_get_non_sse_app_returns_403(self, _ip, _throttle, _perm): + app = _make_app(org=self.org, sse_enabled=False) + request = _build_detail_request("get", f"/public/v1/apps/{app.id}/", app) + response = self.view(request, app_id=app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_get_no_permission_returns_403(self, _ip, _throttle, _perm): + request = _build_detail_request( + "get", f"/public/v1/apps/{self.app.id}/", self.app, role_name="Developer" + ) + # Developer has read permission on Apps by default, but we mocked it to False + response = self.view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicAppDetailView — Update +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicAppDetailViewUpdate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicAppDetailView.as_view() + self.org = _make_org() + self.app = _make_app(org=self.org) + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_name_success(self, _ip, _throttle, _perm, mock_serializer): + mock_serializer.return_value.data = {"id": str(self.app.id), "name": "new-name"} + + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={"name": "new-name"}, + ) + response = self.view(request, app_id=self.app.id) + + assert response.status_code == status.HTTP_200_OK + assert self.app.name == "new-name" + self.app.save.assert_called_once() + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_description_success(self, _ip, _throttle, _perm, mock_serializer): + mock_serializer.return_value.data = { + "id": str(self.app.id), "name": "my-app", "description": "new desc" + } + + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={"description": "new desc"}, + ) + response = self.view(request, app_id=self.app.id) + + assert response.status_code == status.HTTP_200_OK + assert self.app.description == "new desc" + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_no_fields_returns_400(self, _ip, _throttle, _perm): + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={}, + ) + response = self.view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_blank_name_returns_400(self, _ip, _throttle, _perm): + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={"name": ""}, + ) + response = self.view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_name_too_long_returns_400(self, _ip, _throttle, _perm): + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={"name": "x" * 65}, + ) + response = self.view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_description_too_long_returns_400(self, _ip, _throttle, _perm): + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={"description": "x" * 10001}, + ) + response = self.view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicAppDetailView — Delete +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicAppDetailViewDelete: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + settings.APP_HOST = "self" + self.view = PublicAppDetailView.as_view() + self.org = _make_org() + self.app = _make_app(org=self.org) + + @patch("api.views.apps.CLOUD_HOSTED", False) + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_delete_app_success(self, _ip, _throttle, _perm): + request = _build_detail_request( + "delete", f"/public/v1/apps/{self.app.id}/", self.app, + ) + response = self.view(request, app_id=self.app.id) + + assert response.status_code == status.HTTP_204_NO_CONTENT + self.app.save.assert_called_once() + self.app.delete.assert_called_once() + assert self.app.wrapped_key_share == "" + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_delete_no_permission_returns_403(self, _ip, _throttle, _perm): + request = _build_detail_request( + "delete", f"/public/v1/apps/{self.app.id}/", self.app, + role_name="Developer", + ) + response = self.view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# SSE gate tests +# ════════════════════════════════════════════════════════════════════ + + +class TestAppsAPISSEGate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicAppDetailView.as_view() + self.org = _make_org() + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_detail_rejects_non_sse_app(self, _ip, _throttle, _perm): + app = _make_app(org=self.org, sse_enabled=False) + request = _build_detail_request("get", f"/public/v1/apps/{app.id}/", app) + response = self.view(request, app_id=app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_update_rejects_non_sse_app(self, _ip, _throttle, _perm): + app = _make_app(org=self.org, sse_enabled=False) + request = _build_detail_request( + "put", f"/public/v1/apps/{app.id}/", app, data={"name": "new"} + ) + response = self.view(request, app_id=app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_delete_rejects_non_sse_app(self, _ip, _throttle, _perm): + app = _make_app(org=self.org, sse_enabled=False) + request = _build_detail_request("delete", f"/public/v1/apps/{app.id}/", app) + response = self.view(request, app_id=app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# RBAC tests +# ════════════════════════════════════════════════════════════════════ + + +class TestAppsAPIRBAC: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.list_view = PublicAppsView.as_view() + self.detail_view = PublicAppDetailView.as_view() + self.org = _make_org() + self.app = _make_app(org=self.org) + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_developer_cannot_create(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", "/public/v1/apps/", self.org, + data={"name": "new-app"}, + role_name="Developer", + ) + response = self.list_view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_developer_cannot_delete(self, _ip, _throttle, _perm): + request = _build_detail_request( + "delete", f"/public/v1/apps/{self.app.id}/", self.app, + role_name="Developer", + ) + response = self.detail_view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.user_has_permission", return_value=False) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_developer_cannot_update(self, _ip, _throttle, _perm): + request = _build_detail_request( + "put", f"/public/v1/apps/{self.app.id}/", self.app, + data={"name": "new"}, + role_name="Developer", + ) + response = self.detail_view(request, app_id=self.app.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.apps.AppSerializer") + @patch("api.views.apps.App") + @patch("api.views.apps.user_has_permission", return_value=True) + @patch("api.views.apps.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.apps.IsIPAllowed.has_permission", return_value=True) + def test_owner_can_list(self, _ip, _throttle, _perm, mock_app_model, mock_serializer): + mock_org_qs = MagicMock() + mock_filtered = MagicMock() + mock_filtered.order_by.return_value = [] + mock_org_qs.filter.return_value = mock_filtered + mock_app_model.objects.filter.return_value = mock_org_qs + mock_serializer.return_value.data = [] + + request = _build_list_request("get", "/public/v1/apps/", self.org, role_name="Owner") + response = self.list_view(request) + assert response.status_code == status.HTTP_200_OK From ded69a4c53f597ad0558acf509bf4f646f719f61 Mon Sep 17 00:00:00 2001 From: rohan Date: Fri, 13 Mar 2026 15:43:11 +0530 Subject: [PATCH 04/20] feat: rest api for roles, service accounts Signed-off-by: rohan --- backend/api/utils/service_accounts.py | 50 ++ backend/api/views/roles.py | 331 +++++++++ backend/api/views/service_accounts.py | 627 ++++++++++++++++++ backend/backend/urls.py | 11 + backend/tests/api/views/test_roles_api.py | 498 ++++++++++++++ .../api/views/test_service_accounts_api.py | 501 ++++++++++++++ 6 files changed, 2018 insertions(+) create mode 100644 backend/api/utils/service_accounts.py create mode 100644 backend/api/views/roles.py create mode 100644 backend/api/views/service_accounts.py create mode 100644 backend/tests/api/views/test_roles_api.py create mode 100644 backend/tests/api/views/test_service_accounts_api.py diff --git a/backend/api/utils/service_accounts.py b/backend/api/utils/service_accounts.py new file mode 100644 index 000000000..109045b42 --- /dev/null +++ b/backend/api/utils/service_accounts.py @@ -0,0 +1,50 @@ +""" +Server-side utilities for creating service accounts with server-managed +cryptographic keys. + +This generates an Ed25519 signing keypair for the service account, wraps it +with the server's public key (asymmetric encryption), and stores it as +``server_wrapped_keyring`` — enabling the server to later unwrap the keys +for operations like minting tokens or wrapping environment secrets. +""" + +import json + +from nacl.bindings import crypto_sign_keypair + +from api.utils.crypto import encrypt_asymmetric, get_server_keypair + + +def generate_server_managed_sa_keys() -> tuple[str, str, str]: + """ + Generate a new Ed25519 keypair and wrap it for server-side key management. + + Returns: + A tuple of ``(identity_key, server_wrapped_keyring, server_wrapped_recovery)``: + + - **identity_key**: The Ed25519 public key as a hex string. + - **server_wrapped_keyring**: The JSON-encoded keyring + ``{"publicKey": ..., "privateKey": ...}`` encrypted with the server's + public key in ``ph:v1:...`` format. + - **server_wrapped_recovery**: Same payload encrypted a second time as + the recovery copy. + """ + # Generate a random Ed25519 signing keypair + ed_pub, ed_priv = crypto_sign_keypair() + + identity_key = ed_pub.hex() + + # Build the keyring JSON that mirrors the client-side format + keyring_json = json.dumps( + { + "publicKey": ed_pub.hex(), + "privateKey": ed_priv.hex(), + } + ) + + # Wrap with the server's public key + server_pk, _server_sk = get_server_keypair() + server_wrapped_keyring = encrypt_asymmetric(keyring_json, server_pk.hex()) + server_wrapped_recovery = encrypt_asymmetric(keyring_json, server_pk.hex()) + + return identity_key, server_wrapped_keyring, server_wrapped_recovery diff --git a/backend/api/views/roles.py b/backend/api/views/roles.py new file mode 100644 index 000000000..d77ee61ad --- /dev/null +++ b/backend/api/views/roles.py @@ -0,0 +1,331 @@ +import logging + +from django.core.exceptions import ObjectDoesNotExist + +from api.auth import PhaseTokenAuthentication +from api.models import Organisation, OrganisationMember, Role, ServiceAccount +from api.utils.access.permissions import user_has_permission +from api.utils.access.roles import default_roles +from api.utils.rest import METHOD_TO_ACTION +from api.throttling import PlanBasedRateThrottle +from api.utils.access.middleware import IsIPAllowed + +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework import status +from djangorestframework_camel_case.render import CamelCaseJSONRenderer + +logger = logging.getLogger(__name__) + + +def _get_role_permissions(role): + """Get permissions for a role — from default_roles dict if default, otherwise from the stored JSONField.""" + if role.is_default and role.name in default_roles: + return default_roles[role.name] + return role.permissions + + +def _serialize_role(role, include_permissions=False): + """Serialize a Role to a dict.""" + data = { + "id": role.id, + "name": role.name, + "description": role.description, + "color": role.color, + "is_default": role.is_default, + "created_at": role.created_at, + } + if include_permissions: + data["permissions"] = _get_role_permissions(role) + return data + + +class PublicRolesView(APIView): + """List roles and create custom roles for an organisation.""" + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + 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}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission(account, action, "Roles", org, False, is_sa): + raise PermissionDenied( + f"You don't have permission to {action} roles." + ) + + def get(self, request, *args, **kwargs): + org = self._get_org(request) + roles = Role.objects.filter(organisation=org).order_by("created_at") + return Response( + [_serialize_role(r) for r in roles], + status=status.HTTP_200_OK, + ) + + def post(self, request, *args, **kwargs): + org = self._get_org(request) + + # Free plan gate + if org.plan == Organisation.FREE_PLAN: + return Response( + {"error": "Custom roles are not available on your organisation's plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Validate name + 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": "Role name cannot exceed 64 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Duplicate name check + if Role.objects.filter(organisation=org, name__iexact=name).exists(): + return Response( + {"error": "A role with this name already exists."}, + status=status.HTTP_409_CONFLICT, + ) + + # Validate permissions + permissions = request.data.get("permissions") + if not permissions or not isinstance(permissions, dict): + return Response( + {"error": "Missing required field: permissions (must be a JSON object)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Optional fields + description = request.data.get("description", "") + if description and len(str(description)) > 500: + return Response( + {"error": "Role description cannot exceed 500 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + color = request.data.get("color", "") + if color and len(str(color)) > 7: + return Response( + {"error": "Role color cannot exceed 7 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + role = Role.objects.create( + organisation=org, + name=name, + description=description or "", + color=color or "", + permissions=permissions, + ) + + return Response( + _serialize_role(role, include_permissions=True), + status=status.HTTP_201_CREATED, + ) + + +class PublicRoleDetailView(APIView): + """Get, update, or delete a specific role.""" + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + 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}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission(account, action, "Roles", org, False, is_sa): + raise PermissionDenied( + f"You don't have permission to {action} roles." + ) + + def get(self, request, role_id, *args, **kwargs): + org = self._get_org(request) + try: + role = Role.objects.get(id=role_id, organisation=org) + except ObjectDoesNotExist: + return Response( + {"error": "Role not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + _serialize_role(role, include_permissions=True), + status=status.HTTP_200_OK, + ) + + def put(self, request, role_id, *args, **kwargs): + org = self._get_org(request) + try: + role = Role.objects.get(id=role_id, organisation=org) + except ObjectDoesNotExist: + return Response( + {"error": "Role not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if role.is_default: + return Response( + {"error": "Default roles cannot be modified."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Free plan gate + if org.plan == Organisation.FREE_PLAN: + return Response( + {"error": "Custom roles are not available on your organisation's plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + name = request.data.get("name") + description = request.data.get("description") + color = request.data.get("color") + permissions = request.data.get("permissions") + + if name is None and description is None and color is None and permissions is None: + return Response( + {"error": "At least one field must be provided."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if name is not None: + if not str(name).strip(): + return Response( + {"error": "Role name cannot be blank."}, + status=status.HTTP_400_BAD_REQUEST, + ) + name = str(name).strip() + if len(name) > 64: + return Response( + {"error": "Role name cannot exceed 64 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Duplicate name check (exclude self) + if ( + Role.objects.filter(organisation=org, name__iexact=name) + .exclude(id=role_id) + .exists() + ): + return Response( + {"error": "A role with this name already exists."}, + status=status.HTTP_409_CONFLICT, + ) + role.name = name + + if description is not None: + if len(str(description)) > 500: + return Response( + {"error": "Role description cannot exceed 500 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + role.description = description + + if color is not None: + if len(str(color)) > 7: + return Response( + {"error": "Role color cannot exceed 7 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + role.color = color + + if permissions is not None: + if not isinstance(permissions, dict): + return Response( + {"error": "Permissions must be a JSON object."}, + status=status.HTTP_400_BAD_REQUEST, + ) + role.permissions = permissions + + role.save() + + return Response( + _serialize_role(role, include_permissions=True), + status=status.HTTP_200_OK, + ) + + def delete(self, request, role_id, *args, **kwargs): + org = self._get_org(request) + try: + role = Role.objects.get(id=role_id, organisation=org) + except ObjectDoesNotExist: + return Response( + {"error": "Role not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if role.is_default: + return Response( + {"error": "Default roles cannot be deleted."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Check for assigned members + if OrganisationMember.objects.filter(role=role, deleted_at=None).exists(): + return Response( + {"error": "Cannot delete a role that has members assigned to it."}, + status=status.HTTP_409_CONFLICT, + ) + + # Check for assigned service accounts + if ServiceAccount.objects.filter(role=role, deleted_at=None).exists(): + return Response( + {"error": "Cannot delete a role that has service accounts assigned to it."}, + status=status.HTTP_409_CONFLICT, + ) + + role.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/views/service_accounts.py b/backend/api/views/service_accounts.py new file mode 100644 index 000000000..978c7d48b --- /dev/null +++ b/backend/api/views/service_accounts.py @@ -0,0 +1,627 @@ +import json +import logging + +from django.core.exceptions import ObjectDoesNotExist + +from api.auth import PhaseTokenAuthentication +from api.models import ( + App, + Environment, + EnvironmentKey, + OrganisationMember, + Role, + ServiceAccount, + ServiceAccountToken, +) +from api.serializers import ServiceAccountSerializer +from api.utils.access.permissions import ( + role_has_global_access, + user_has_permission, +) +from api.utils.crypto import ( + decrypt_asymmetric, + ed25519_to_kx, + encrypt_asymmetric, + get_server_keypair, + random_hex, + split_secret_hex, + wrap_share_hex, +) +from api.utils.environments import _ed25519_pk_to_curve25519, _wrap_env_secrets_for_key +from api.utils.rest import METHOD_TO_ACTION +from api.utils.service_accounts import generate_server_managed_sa_keys +from api.throttling import PlanBasedRateThrottle +from api.utils.access.middleware import IsIPAllowed + +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework import status +from djangorestframework_camel_case.render import CamelCaseJSONRenderer +from django.conf import settings +from django.db import transaction + +logger = logging.getLogger(__name__) + +CLOUD_HOSTED = settings.APP_HOST == "cloud" + + +def _serialize_sa(sa): + """Serialize a ServiceAccount with token count and timestamps.""" + data = ServiceAccountSerializer(sa).data + data["created_at"] = sa.created_at + data["updated_at"] = sa.updated_at + return data + + +def _serialize_sa_detail(sa): + """Serialize a ServiceAccount with full detail including tokens and app access.""" + data = _serialize_sa(sa) + + # Include non-deleted tokens (name, id, created_at only — no secret material) + tokens = sa.serviceaccounttoken_set.filter(deleted_at=None).order_by("-created_at") + data["tokens"] = [ + { + "id": t.id, + "name": t.name, + "created_at": t.created_at, + "expires_at": t.expires_at, + } + for t in tokens + ] + + # Include app memberships with environment access + apps_data = [] + for app in sa.apps.filter(is_deleted=False).order_by("-created_at"): + env_keys = EnvironmentKey.objects.filter( + service_account=sa, + environment__app=app, + deleted_at=None, + ).select_related("environment") + apps_data.append( + { + "id": str(app.id), + "name": app.name, + "environments": [ + { + "id": str(ek.environment.id), + "name": ek.environment.name, + "env_type": ek.environment.env_type, + } + for ek in env_keys + ], + } + ) + data["apps"] = apps_data + + return data + + +class PublicServiceAccountsView(APIView): + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + 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}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission( + account, action, "ServiceAccounts", org, False, is_sa + ): + raise PermissionDenied( + f"You don't have permission to {action} service accounts." + ) + + def get(self, request, *args, **kwargs): + org = self._get_org(request) + + service_accounts = ServiceAccount.objects.filter( + organisation=org, + deleted_at=None, + ).select_related("role").order_by("-created_at") + + data = [_serialize_sa(sa) for sa in service_accounts] + return Response(data, status=status.HTTP_200_OK) + + 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": "Service account name cannot exceed 64 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # --- Validate role --- + role_id = request.data.get("role_id") + if not role_id: + return Response( + {"error": "Missing required field: role_id"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + role = Role.objects.get(id=role_id, organisation=org) + except (ObjectDoesNotExist, ValueError): + return Response( + {"error": "Role not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if role_has_global_access(role): + return Response( + {"error": f"Service accounts cannot be assigned the '{role.name}' role."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # --- Generate cryptographic material --- + identity_key, server_wrapped_keyring, server_wrapped_recovery = ( + generate_server_managed_sa_keys() + ) + + # --- Create service account --- + try: + with transaction.atomic(): + sa = ServiceAccount.objects.create( + name=name, + organisation=org, + role=role, + identity_key=identity_key, + server_wrapped_keyring=server_wrapped_keyring, + server_wrapped_recovery=server_wrapped_recovery, + ) + except ValueError as e: + return Response( + {"error": str(e)}, + status=status.HTTP_403_FORBIDDEN, + ) + + if CLOUD_HOSTED: + from ee.billing.stripe import update_stripe_subscription_seats + + update_stripe_subscription_seats(org) + + # --- Mint an initial token --- + token_name = request.data.get("token_name", "Default") + + pk, sk = get_server_keypair() + keyring_json = decrypt_asymmetric( + sa.server_wrapped_keyring, sk.hex(), pk.hex() + ) + keyring = json.loads(keyring_json) + kx_pub, kx_priv = ed25519_to_kx(keyring["publicKey"], keyring["privateKey"]) + + wrap_key = random_hex(32) + token_value = random_hex(32) + share_a, share_b = split_secret_hex(kx_priv) + wrapped_share_b = wrap_share_hex(share_b, wrap_key) + + # Determine creator + created_by = None + created_by_sa = None + if request.auth["auth_type"] == "User": + created_by = request.auth["org_member"] + elif request.auth["auth_type"] == "ServiceAccount": + created_by_sa = request.auth["service_account"] + + ServiceAccountToken.objects.create( + service_account=sa, + name=str(token_name).strip()[:64], + identity_key=kx_pub, + token=token_value, + wrapped_key_share=wrapped_share_b, + created_by=created_by, + created_by_service_account=created_by_sa, + ) + + full_token = f"pss_service:v2:{token_value}:{kx_pub}:{share_a}:{wrap_key}" + bearer_token = f"ServiceAccount {token_value}" + + response_data = _serialize_sa(sa) + response_data["token"] = full_token + response_data["bearer_token"] = bearer_token + return Response(response_data, status=status.HTTP_201_CREATED) + + +class PublicServiceAccountDetailView(APIView): + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + 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}") + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission( + account, action, "ServiceAccounts", org, False, is_sa + ): + raise PermissionDenied( + f"You don't have permission to {action} service accounts." + ) + + def _get_service_account(self, request, sa_id): + org = self._get_org(request) + try: + return ServiceAccount.objects.select_related("role").get( + id=sa_id, + organisation=org, + deleted_at=None, + ) + except (ObjectDoesNotExist, ValueError): + return None + + def get(self, request, sa_id, *args, **kwargs): + sa = self._get_service_account(request, sa_id) + if sa is None: + return Response( + {"error": "Service account not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(_serialize_sa_detail(sa), status=status.HTTP_200_OK) + + def put(self, request, sa_id, *args, **kwargs): + sa = self._get_service_account(request, sa_id) + if sa is None: + return Response( + {"error": "Service account not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + name = request.data.get("name") + role_id = request.data.get("role_id") + + if name is None and role_id is None: + return Response( + {"error": "At least one of 'name' or 'role_id' must be provided."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if name is not None: + if not name or str(name).strip() == "": + return Response( + {"error": "Service account name cannot be blank."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(str(name)) > 64: + return Response( + {"error": "Service account name cannot exceed 64 characters."}, + status=status.HTTP_400_BAD_REQUEST, + ) + sa.name = str(name).strip() + + if role_id is not None: + try: + role = Role.objects.get(id=role_id, organisation=sa.organisation) + except (ObjectDoesNotExist, ValueError): + return Response( + {"error": "Role not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + if role_has_global_access(role): + return Response( + {"error": f"Service accounts cannot be assigned the '{role.name}' role."}, + status=status.HTTP_400_BAD_REQUEST, + ) + sa.role = role + + sa.save() + return Response(_serialize_sa_detail(sa), status=status.HTTP_200_OK) + + def delete(self, request, sa_id, *args, **kwargs): + sa = self._get_service_account(request, sa_id) + if sa is None: + return Response( + {"error": "Service account not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + sa.delete() + + if CLOUD_HOSTED: + from ee.billing.stripe import update_stripe_subscription_seats + + update_stripe_subscription_seats(sa.organisation) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PublicServiceAccountAccessView(APIView): + """ + Manage app and environment access for a service account. + + PUT /service-accounts//access/ + + Request body: + { + "apps": [ + { + "id": "", + "environments": ["", ""] + }, + ... + ] + } + + This replaces the service account's entire access configuration. + - Apps not in the list are removed. + - Environment access is granted by creating EnvironmentKey records + with server-wrapped keys for the SA's identity key. + - Environments not in the list for a given app are revoked. + """ + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + # Access management requires "update" on ServiceAccounts + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission( + account, "update", "ServiceAccounts", org, False, is_sa + ): + raise PermissionDenied( + "You don't have permission to update service accounts." + ) + + def put(self, request, sa_id, *args, **kwargs): + org = self._get_org(request) + + try: + sa = ServiceAccount.objects.select_related("role").get( + id=sa_id, + organisation=org, + deleted_at=None, + ) + except (ObjectDoesNotExist, ValueError): + return Response( + {"error": "Service account not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # --- Validate input --- + apps_input = request.data.get("apps") + if apps_input is None: + return Response( + {"error": "Missing required field: apps"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(apps_input, list): + return Response( + {"error": "'apps' must be a list."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # --- Resolve all apps and environments upfront --- + desired_app_ids = set() + desired_env_map = {} # app_id -> set of env_ids + + for app_entry in apps_input: + if not isinstance(app_entry, dict): + return Response( + {"error": "Each entry in 'apps' must be an object with 'id' and 'environments'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + app_id = app_entry.get("id") + if not app_id: + return Response( + {"error": "Each app entry must have an 'id' field."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + app = App.objects.get(id=app_id, organisation=org, is_deleted=False) + except (ObjectDoesNotExist, ValueError): + return Response( + {"error": f"App not found: {app_id}"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not app.sse_enabled: + return Response( + {"error": f"App '{app.name}' does not have SSE enabled. Only SSE apps are supported."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + env_ids = app_entry.get("environments", []) + if not isinstance(env_ids, list): + return Response( + {"error": f"'environments' for app '{app_id}' must be a list of environment IDs."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate all env IDs belong to this app + valid_envs = set( + Environment.objects.filter( + app=app, + id__in=env_ids, + ).values_list("id", flat=True) + ) + invalid_ids = set(str(e) for e in env_ids) - set(str(e) for e in valid_envs) + if invalid_ids: + return Response( + {"error": f"Environment(s) not found in app '{app.name}': {', '.join(invalid_ids)}"}, + status=status.HTTP_404_NOT_FOUND, + ) + + desired_app_ids.add(str(app.id)) + desired_env_map[str(app.id)] = valid_envs + + # --- Check SA has server-wrapped keyring for key wrapping --- + if not sa.server_wrapped_keyring: + return Response( + {"error": "Service account does not have server-side key management enabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Decrypt SA keyring to get its public key for env key wrapping + server_pk, server_sk = get_server_keypair() + keyring_json = decrypt_asymmetric( + sa.server_wrapped_keyring, server_sk.hex(), server_pk.hex() + ) + keyring = json.loads(keyring_json) + sa_kx_pub = _ed25519_pk_to_curve25519(keyring["publicKey"]) + + # --- Apply changes atomically --- + with transaction.atomic(): + # 1. Remove app memberships not in the desired list + current_app_ids = set( + str(a.id) for a in sa.apps.filter(is_deleted=False) + ) + apps_to_remove = current_app_ids - desired_app_ids + if apps_to_remove: + apps_to_remove_qs = App.objects.filter(id__in=apps_to_remove) + sa.apps.remove(*apps_to_remove_qs) + # Soft-delete env keys for removed apps + EnvironmentKey.objects.filter( + service_account=sa, + environment__app__id__in=apps_to_remove, + deleted_at=None, + ).update(deleted_at=sa.updated_at) + + # 2. Add new app memberships + apps_to_add = desired_app_ids - current_app_ids + if apps_to_add: + sa.apps.add(*App.objects.filter(id__in=apps_to_add)) + + # 3. Sync environment access per app + for app_id_str, desired_envs in desired_env_map.items(): + desired_env_id_strs = set(str(e) for e in desired_envs) + + # Get current env keys for this SA + app + current_env_keys = EnvironmentKey.objects.filter( + service_account=sa, + environment__app_id=app_id_str, + deleted_at=None, + ) + current_env_ids = set( + str(ek.environment_id) for ek in current_env_keys + ) + + # Revoke access to environments not in the desired list + envs_to_revoke = current_env_ids - desired_env_id_strs + if envs_to_revoke: + EnvironmentKey.objects.filter( + service_account=sa, + environment_id__in=envs_to_revoke, + deleted_at=None, + ).update(deleted_at=sa.updated_at) + + # Grant access to new environments + envs_to_grant = desired_env_id_strs - current_env_ids + if envs_to_grant: + from api.models import ServerEnvironmentKey + + new_env_keys = [] + for env_id in envs_to_grant: + # Get the ServerEnvironmentKey to obtain the env seed/salt + try: + sek = ServerEnvironmentKey.objects.get( + environment_id=env_id, + deleted_at=None, + ) + except ObjectDoesNotExist: + logger.warning( + "No ServerEnvironmentKey for env %s — skipping.", + env_id, + ) + continue + + # Decrypt env seed/salt from server key + env_seed = decrypt_asymmetric( + sek.wrapped_seed, server_sk.hex(), server_pk.hex() + ) + env_salt = decrypt_asymmetric( + sek.wrapped_salt, server_sk.hex(), server_pk.hex() + ) + + # Re-wrap for the SA's identity key + wrapped_seed, wrapped_salt = _wrap_env_secrets_for_key( + env_seed, env_salt, sa_kx_pub + ) + + env = Environment.objects.get(id=env_id) + new_env_keys.append( + EnvironmentKey( + environment=env, + service_account=sa, + identity_key=sek.identity_key, + wrapped_seed=wrapped_seed, + wrapped_salt=wrapped_salt, + ) + ) + + if new_env_keys: + EnvironmentKey.objects.bulk_create(new_env_keys) + + return Response(_serialize_sa_detail(sa), status=status.HTTP_200_OK) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 16d2bb4bf..2c49992c3 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -7,6 +7,12 @@ from api.views.apps import PublicAppsView, PublicAppDetailView from api.views.environments import PublicEnvironmentsView, PublicEnvironmentDetailView from api.views.secrets import E2EESecretsView, PublicSecretsView +from api.views.service_accounts import ( + PublicServiceAccountsView, + PublicServiceAccountDetailView, + PublicServiceAccountAccessView, +) +from api.views.roles import PublicRolesView, PublicRoleDetailView from api.views.auth import ( logout_view, health_check, @@ -50,6 +56,11 @@ path("public/v1/apps//", PublicAppDetailView.as_view()), path("public/v1/environments/", PublicEnvironmentsView.as_view()), path("public/v1/environments//", PublicEnvironmentDetailView.as_view()), + path("public/v1/service-accounts/", PublicServiceAccountsView.as_view()), + path("public/v1/service-accounts//", PublicServiceAccountDetailView.as_view()), + path("public/v1/service-accounts//access/", PublicServiceAccountAccessView.as_view()), + path("public/v1/roles/", PublicRolesView.as_view()), + path("public/v1/roles//", PublicRoleDetailView.as_view()), path( "public/v1/secrets/dynamic/", include("ee.integrations.secrets.dynamic.rest.urls"), diff --git a/backend/tests/api/views/test_roles_api.py b/backend/tests/api/views/test_roles_api.py new file mode 100644 index 000000000..3b1c2bdaf --- /dev/null +++ b/backend/tests/api/views/test_roles_api.py @@ -0,0 +1,498 @@ +import uuid +import pytest +from unittest.mock import Mock, MagicMock, patch, PropertyMock +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework import status +from django.core.exceptions import ObjectDoesNotExist + +from api.views.roles import PublicRolesView, PublicRoleDetailView + + +# ──────────────────────────────────────────────────────────────────── +# Auto-patch IsIPAllowed for all tests in this module +# ──────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _bypass_ip_check(): + with patch("api.views.roles.IsIPAllowed.has_permission", return_value=True): + yield + + +# ──────────────────────────────────────────────────────────────────── +# Shared test helpers +# ──────────────────────────────────────────────────────────────────── + +FREE_PLAN = "FR" +PRO_PLAN = "PR" + + +def _make_org(plan=PRO_PLAN, org_id=None): + org = Mock() + org.id = org_id or uuid.uuid4() + org.plan = plan + org.FREE_PLAN = FREE_PLAN + return org + + +def _make_role(name="Developer", role_id=None, org=None, is_default=True, permissions=None): + role = Mock() + role.id = role_id or str(uuid.uuid4()) + role.name = name + role.description = f"Default {name} role" + role.color = "#000000" + role.is_default = is_default + role.organisation = org + role.permissions = permissions or {} + role.created_at = "2024-01-01T00:00:00Z" + role.save = Mock() + role.delete = Mock() + return role + + +def _make_user(): + user = Mock() + user.userId = uuid.uuid4() + user.id = user.userId + user.is_authenticated = True + user.is_active = True + return user + + +def _make_org_member(org=None, role_name="Owner"): + member = Mock() + member.id = uuid.uuid4() + member.user = _make_user() + member.organisation = org or _make_org() + member.deleted_at = None + member.role = Mock() + member.role.name = role_name + return member + + +def _make_auth(org, auth_type="User", org_member=None, service_account=None): + return { + "token": "Bearer User test_token", + "auth_type": auth_type, + "app": None, + "environment": None, + "org_member": org_member, + "service_token": None, + "service_account": service_account, + "service_account_token": None, + "organisation": org, + "org_only": True, + } + + +def _build_request(method, url, org, data=None, auth_type="User", role_name="Owner"): + factory = APIRequestFactory() + if method == "get": + request = factory.get(url) + elif method == "post": + request = factory.post(url, data=data, format="json") + elif method == "put": + request = factory.put(url, data=data, format="json") + elif method == "delete": + request = factory.delete(url) + else: + raise ValueError(f"Unknown method: {method}") + + org_member = _make_org_member(org, role_name=role_name) + user = org_member.user + auth = _make_auth(org, auth_type=auth_type, org_member=org_member) + force_authenticate(request, user=user, token=auth) + return request + + +# ──────────────────────────────────────────────────────────────────── +# PublicRolesView tests +# ──────────────────────────────────────────────────────────────────── + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Role") +def test_list_roles_200(mock_role_cls, mock_perm): + org = _make_org() + roles = [ + _make_role("Owner", org=org), + _make_role("Admin", org=org), + _make_role("Developer", org=org), + ] + mock_role_cls.objects.filter.return_value.order_by.return_value = roles + + request = _build_request("get", "/public/v1/roles/", org) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 3 + + +@patch("api.views.roles.user_has_permission", return_value=False) +def test_list_roles_permission_denied(mock_perm): + org = _make_org() + request = _build_request("get", "/public/v1/roles/", org) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_create_role_201(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + + permissions = { + "permissions": {"Apps": ["read"]}, + "app_permissions": {"Secrets": ["read"]}, + "global_access": False, + } + + created_role = _make_role( + "CustomRole", + org=org, + is_default=False, + permissions=permissions, + ) + mock_role_cls.objects.filter.return_value.exists.return_value = False + mock_role_cls.objects.create.return_value = created_role + + request = _build_request( + "post", + "/public/v1/roles/", + org, + data={ + "name": "CustomRole", + "permissions": permissions, + "description": "A custom role", + "color": "#FF0000", + }, + ) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_201_CREATED + mock_role_cls.objects.create.assert_called_once() + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_create_role_missing_name_400(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + + request = _build_request( + "post", + "/public/v1/roles/", + org, + data={"permissions": {"permissions": {}}}, + ) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_create_role_missing_permissions_400(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + mock_role_cls.objects.filter.return_value.exists.return_value = False + + request = _build_request( + "post", + "/public/v1/roles/", + org, + data={"name": "TestRole"}, + ) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_create_role_duplicate_name_409(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + + mock_role_cls.objects.filter.return_value.exists.return_value = True + + request = _build_request( + "post", + "/public/v1/roles/", + org, + data={"name": "Owner", "permissions": {"permissions": {}}}, + ) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_409_CONFLICT + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +def test_create_role_free_plan_403(mock_org_cls, mock_perm): + org = _make_org(plan=FREE_PLAN) + mock_org_cls.FREE_PLAN = FREE_PLAN + + request = _build_request( + "post", + "/public/v1/roles/", + org, + data={"name": "Custom", "permissions": {"permissions": {}}}, + ) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_create_role_name_too_long_400(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + + request = _build_request( + "post", + "/public/v1/roles/", + org, + data={"name": "x" * 65, "permissions": {"permissions": {}}}, + ) + view = PublicRolesView.as_view() + response = view(request) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# ──────────────────────────────────────────────────────────────────── +# PublicRoleDetailView tests +# ──────────────────────────────────────────────────────────────────── + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Role") +def test_get_role_200(mock_role_cls, mock_perm): + org = _make_org() + role = _make_role("Owner", org=org) + mock_role_cls.objects.get.return_value = role + + request = _build_request("get", f"/public/v1/roles/{role.id}/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Owner" + assert "permissions" in response.data + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Role") +def test_get_role_not_found_404(mock_role_cls, mock_perm): + org = _make_org() + mock_role_cls.objects.get.side_effect = ObjectDoesNotExist + + request = _build_request("get", "/public/v1/roles/nonexistent/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id="nonexistent") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_update_role_200(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + mock_role_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False + + request = _build_request( + "put", + f"/public/v1/roles/{role.id}/", + org, + data={"name": "UpdatedRole"}, + ) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_200_OK + role.save.assert_called_once() + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_update_default_role_403(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + role = _make_role("Owner", org=org, is_default=True) + mock_role_cls.objects.get.return_value = role + + request = _build_request( + "put", + f"/public/v1/roles/{role.id}/", + org, + data={"name": "NewOwner"}, + ) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_update_role_no_fields_400(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + + request = _build_request( + "put", + f"/public/v1/roles/{role.id}/", + org, + data={}, + ) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_update_role_blank_name_400(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + + request = _build_request( + "put", + f"/public/v1/roles/{role.id}/", + org, + data={"name": " "}, + ) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Organisation") +@patch("api.views.roles.Role") +def test_update_role_duplicate_name_409(mock_role_cls, mock_org_cls, mock_perm): + org = _make_org() + mock_org_cls.FREE_PLAN = FREE_PLAN + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + mock_role_cls.objects.filter.return_value.exclude.return_value.exists.return_value = True + + request = _build_request( + "put", + f"/public/v1/roles/{role.id}/", + org, + data={"name": "ExistingRole"}, + ) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_409_CONFLICT + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.ServiceAccount") +@patch("api.views.roles.OrganisationMember") +@patch("api.views.roles.Role") +def test_delete_role_204(mock_role_cls, mock_member_cls, mock_sa_cls, mock_perm): + org = _make_org() + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + mock_member_cls.objects.filter.return_value.exists.return_value = False + mock_sa_cls.objects.filter.return_value.exists.return_value = False + + request = _build_request("delete", f"/public/v1/roles/{role.id}/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_204_NO_CONTENT + role.delete.assert_called_once() + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Role") +def test_delete_default_role_403(mock_role_cls, mock_perm): + org = _make_org() + role = _make_role("Owner", org=org, is_default=True) + mock_role_cls.objects.get.return_value = role + + request = _build_request("delete", f"/public/v1/roles/{role.id}/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.Role") +def test_delete_role_not_found_404(mock_role_cls, mock_perm): + org = _make_org() + mock_role_cls.objects.get.side_effect = ObjectDoesNotExist + + request = _build_request("delete", "/public/v1/roles/nonexistent/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id="nonexistent") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.ServiceAccount") +@patch("api.views.roles.OrganisationMember") +@patch("api.views.roles.Role") +def test_delete_role_with_members_409(mock_role_cls, mock_member_cls, mock_sa_cls, mock_perm): + org = _make_org() + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + mock_member_cls.objects.filter.return_value.exists.return_value = True + + request = _build_request("delete", f"/public/v1/roles/{role.id}/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_409_CONFLICT + + +@patch("api.views.roles.user_has_permission", return_value=True) +@patch("api.views.roles.ServiceAccount") +@patch("api.views.roles.OrganisationMember") +@patch("api.views.roles.Role") +def test_delete_role_with_service_accounts_409(mock_role_cls, mock_member_cls, mock_sa_cls, mock_perm): + org = _make_org() + role = _make_role("CustomRole", org=org, is_default=False) + mock_role_cls.objects.get.return_value = role + mock_member_cls.objects.filter.return_value.exists.return_value = False + mock_sa_cls.objects.filter.return_value.exists.return_value = True + + request = _build_request("delete", f"/public/v1/roles/{role.id}/", org) + view = PublicRoleDetailView.as_view() + response = view(request, role_id=role.id) + + assert response.status_code == status.HTTP_409_CONFLICT diff --git a/backend/tests/api/views/test_service_accounts_api.py b/backend/tests/api/views/test_service_accounts_api.py new file mode 100644 index 000000000..42a0b2ede --- /dev/null +++ b/backend/tests/api/views/test_service_accounts_api.py @@ -0,0 +1,501 @@ +import uuid +import pytest +from unittest.mock import Mock, MagicMock, patch, PropertyMock +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework import status + +from api.views.service_accounts import ( + PublicServiceAccountsView, + PublicServiceAccountDetailView, + PublicServiceAccountAccessView, +) + + +# ──────────────────────────────────────────────────────────────────── +# Shared test helpers +# ──────────────────────────────────────────────────────────────────── + + +def _make_org(plan="PR", org_id=None): + org = Mock() + org.id = org_id or uuid.uuid4() + org.plan = plan + org.organisation_id = org.id + return org + + +def _make_role(name="Service", role_id=None, org=None, is_default=True, global_access=False): + role = Mock() + role.id = role_id or uuid.uuid4() + role.name = name + role.is_default = is_default + role.organisation = org + role.permissions = { + "global_access": global_access, + } + return role + + +def _make_user(): + user = Mock() + user.userId = uuid.uuid4() + user.id = user.userId + user.is_authenticated = True + user.is_active = True + return user + + +def _make_org_member(org=None, role_name="Owner"): + member = Mock() + member.id = uuid.uuid4() + member.user = _make_user() + member.organisation = org or _make_org() + member.deleted_at = None + member.role = Mock() + member.role.name = role_name + member.apps = Mock() + return member + + +def _make_service_account(org=None, name="test-sa", sa_id=None, role=None): + sa = Mock() + sa.id = sa_id or str(uuid.uuid4()) + sa.name = name + sa.organisation = org or _make_org() + sa.organisation_id = sa.organisation.id + sa.role = role or _make_role(org=sa.organisation) + sa.identity_key = "aa" * 32 + sa.server_wrapped_keyring = "ph:v1:keyring" + sa.server_wrapped_recovery = "ph:v1:recovery" + sa.deleted_at = None + sa.created_at = "2024-01-01T00:00:00Z" + sa.updated_at = "2024-01-01T00:00:00Z" + sa.apps = MagicMock() + sa.serviceaccounttoken_set = MagicMock() + sa.save = Mock() + sa.delete = Mock() + return sa + + +def _make_auth_org_only(org, auth_type="User", org_member=None, service_account=None): + return { + "token": "Bearer User test_token", + "auth_type": auth_type, + "app": None, + "environment": None, + "org_member": org_member, + "service_token": None, + "service_account": service_account, + "service_account_token": None, + "organisation": org, + "org_only": True, + } + + +def _build_list_request(method, url, org, data=None, auth_type="User", role_name="Owner"): + factory = APIRequestFactory() + if method == "get": + request = factory.get(url) + elif method == "post": + request = factory.post(url, data=data, format="json") + else: + raise ValueError(f"Unknown method: {method}") + + org_member = _make_org_member(org=org, role_name=role_name) + + sa = None + if auth_type == "ServiceAccount": + sa = _make_service_account(org=org) + + auth = _make_auth_org_only( + org, + auth_type=auth_type, + org_member=org_member if auth_type == "User" else None, + service_account=sa, + ) + + force_authenticate(request, user=org_member.user, token=auth) + return request + + +def _build_detail_request(method, url, org, data=None, auth_type="User", role_name="Owner"): + factory = APIRequestFactory() + if method == "get": + request = factory.get(url) + elif method == "put": + request = factory.put(url, data=data, format="json") + elif method == "delete": + request = factory.delete(url) + else: + raise ValueError(f"Unknown method: {method}") + + org_member = _make_org_member(org=org, role_name=role_name) + + sa = None + if auth_type == "ServiceAccount": + sa = _make_service_account(org=org) + + auth = _make_auth_org_only( + org, + auth_type=auth_type, + org_member=org_member if auth_type == "User" else None, + service_account=sa, + ) + + force_authenticate(request, user=org_member.user, token=auth) + return request + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicServiceAccountsView — List +# ════════════════════════════════════════════════════════════════════ + + +class TestServiceAccountsList: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicServiceAccountsView.as_view() + self.org = _make_org() + + @patch("api.views.service_accounts.ServiceAccountSerializer") + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_list_returns_200(self, _ip, _throttle, _perm, mock_sa_model, mock_serializer): + sa1 = _make_service_account(org=self.org, name="sa-one") + sa2 = _make_service_account(org=self.org, name="sa-two") + + mock_qs = MagicMock() + mock_related = MagicMock() + mock_ordered = [sa1, sa2] + mock_related.order_by.return_value = mock_ordered + mock_qs.select_related.return_value = mock_related + mock_sa_model.objects.filter.return_value = mock_qs + + mock_serializer.return_value.data = {"id": "1", "name": "sa-one", "role": None} + + request = _build_list_request("get", "/public/v1/service-accounts/", self.org) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + @patch("api.views.service_accounts.user_has_permission", return_value=False) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_list_permission_denied(self, _ip, _throttle, _perm): + request = _build_list_request("get", "/public/v1/service-accounts/", self.org) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicServiceAccountsView — Create +# ════════════════════════════════════════════════════════════════════ + + +class TestServiceAccountsCreate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + settings.APP_HOST = "selfhosted" + self.view = PublicServiceAccountsView.as_view() + self.org = _make_org() + self.role = _make_role(org=self.org) + + @patch("api.views.service_accounts.ServiceAccountSerializer") + @patch("api.views.service_accounts.ServiceAccountToken") + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.Role") + @patch("api.views.service_accounts.generate_server_managed_sa_keys") + @patch("api.views.service_accounts.decrypt_asymmetric") + @patch("api.views.service_accounts.ed25519_to_kx", return_value=("kx_pub", "kx_priv")) + @patch("api.views.service_accounts.random_hex", return_value="aa" * 32) + @patch("api.views.service_accounts.split_secret_hex", return_value=("share_a", "share_b")) + @patch("api.views.service_accounts.wrap_share_hex", return_value="wrapped") + @patch("api.views.service_accounts.get_server_keypair", return_value=(b"\x00" * 32, b"\x01" * 32)) + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.role_has_global_access", return_value=False) + @patch("api.views.service_accounts.transaction") + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_create_returns_201( + self, _ip, _throttle, _tx, _global, _perm, _server_kp, _wrap, _split, + _rand, _kx, _decrypt, _gen_keys, mock_role_model, mock_sa_model, + mock_sat_model, mock_serializer, + ): + role = self.role + mock_role_model.objects.get.return_value = role + + _gen_keys.return_value = ("identity_key", "wrapped_keyring", "wrapped_recovery") + _decrypt.return_value = '{"publicKey": "aabb", "privateKey": "ccdd"}' + + sa = _make_service_account(org=self.org, role=role) + mock_sa_model.objects.create.return_value = sa + + mock_serializer.return_value.data = { + "id": str(sa.id), + "name": sa.name, + "role": {"id": str(role.id), "name": role.name}, + } + + request = _build_list_request( + "post", + "/public/v1/service-accounts/", + self.org, + data={"name": "new-sa", "role_id": str(role.id)}, + ) + response = self.view(request) + + assert response.status_code == status.HTTP_201_CREATED + assert "token" in response.data + assert "bearer_token" in response.data + + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_create_missing_name_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", + "/public/v1/service-accounts/", + self.org, + data={"role_id": str(uuid.uuid4())}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_create_missing_role_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", + "/public/v1/service-accounts/", + self.org, + data={"name": "test-sa"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.service_accounts.Role") + @patch("api.views.service_accounts.role_has_global_access", return_value=True) + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_create_global_role_returns_400(self, _ip, _throttle, _perm, _global, mock_role): + admin_role = _make_role(name="Admin", org=self.org, global_access=True) + mock_role.objects.get.return_value = admin_role + + request = _build_list_request( + "post", + "/public/v1/service-accounts/", + self.org, + data={"name": "test-sa", "role_id": str(admin_role.id)}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Admin" in response.data["error"] + + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_create_name_too_long_returns_400(self, _ip, _throttle, _perm): + request = _build_list_request( + "post", + "/public/v1/service-accounts/", + self.org, + data={"name": "A" * 65, "role_id": str(uuid.uuid4())}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "64" in response.data["error"] + + @patch("api.views.service_accounts.Role") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_create_role_not_found_returns_404(self, _ip, _throttle, _perm, mock_role): + from django.core.exceptions import ObjectDoesNotExist + + mock_role.objects.get.side_effect = ObjectDoesNotExist + + request = _build_list_request( + "post", + "/public/v1/service-accounts/", + self.org, + data={"name": "test-sa", "role_id": str(uuid.uuid4())}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# ════════════════════════════════════════════════════════════════════ +# Tests for PublicServiceAccountDetailView — Get / Update / Delete +# ════════════════════════════════════════════════════════════════════ + + +class TestServiceAccountDetail: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + settings.APP_HOST = "selfhosted" + self.view = PublicServiceAccountDetailView.as_view() + self.org = _make_org() + + @patch("api.views.service_accounts.EnvironmentKey") + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_get_returns_200(self, _ip, _throttle, _perm, mock_sa_model, _ek): + sa = _make_service_account(org=self.org) + sa.serviceaccounttoken_set.filter.return_value.order_by.return_value = [] + sa.apps.filter.return_value.order_by.return_value = [] + mock_sa_model.objects.select_related.return_value.get.return_value = sa + + request = _build_detail_request("get", f"/public/v1/service-accounts/{sa.id}/", self.org) + response = self.view(request, sa_id=str(sa.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == sa.name + + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_get_not_found_returns_404(self, _ip, _throttle, _perm, mock_sa_model): + from django.core.exceptions import ObjectDoesNotExist + + mock_sa_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request = _build_detail_request("get", "/public/v1/service-accounts/bad/", self.org) + response = self.view(request, sa_id="bad") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.service_accounts.EnvironmentKey") + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.Role") + @patch("api.views.service_accounts.role_has_global_access", return_value=False) + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_update_name_returns_200(self, _ip, _throttle, _perm, _global, mock_role, mock_sa_model, _ek): + sa = _make_service_account(org=self.org) + sa.serviceaccounttoken_set.filter.return_value.order_by.return_value = [] + sa.apps.filter.return_value.order_by.return_value = [] + mock_sa_model.objects.select_related.return_value.get.return_value = sa + + request = _build_detail_request( + "put", f"/public/v1/service-accounts/{sa.id}/", self.org, + data={"name": "renamed-sa"}, + ) + response = self.view(request, sa_id=str(sa.id)) + + assert response.status_code == status.HTTP_200_OK + assert sa.name == "renamed-sa" + sa.save.assert_called_once() + + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_update_no_fields_returns_400(self, _ip, _throttle, _perm, mock_sa_model): + sa = _make_service_account(org=self.org) + mock_sa_model.objects.select_related.return_value.get.return_value = sa + + request = _build_detail_request( + "put", f"/public/v1/service-accounts/{sa.id}/", self.org, + data={}, + ) + response = self.view(request, sa_id=str(sa.id)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_delete_returns_204(self, _ip, _throttle, _perm, mock_sa_model): + sa = _make_service_account(org=self.org) + mock_sa_model.objects.select_related.return_value.get.return_value = sa + + request = _build_detail_request("delete", f"/public/v1/service-accounts/{sa.id}/", self.org) + response = self.view(request, sa_id=str(sa.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + sa.delete.assert_called_once() + + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_delete_not_found_returns_404(self, _ip, _throttle, _perm, mock_sa_model): + from django.core.exceptions import ObjectDoesNotExist + + mock_sa_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request = _build_detail_request("delete", "/public/v1/service-accounts/bad/", self.org) + response = self.view(request, sa_id="bad") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.service_accounts.user_has_permission", return_value=False) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_delete_permission_denied(self, _ip, _throttle, _perm): + request = _build_detail_request( + "delete", "/public/v1/service-accounts/some-id/", self.org + ) + response = self.view(request, sa_id="some-id") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_update_blank_name_returns_400(self, _ip, _throttle, _perm, mock_sa_model): + sa = _make_service_account(org=self.org) + mock_sa_model.objects.select_related.return_value.get.return_value = sa + + request = _build_detail_request( + "put", f"/public/v1/service-accounts/{sa.id}/", self.org, + data={"name": " "}, + ) + response = self.view(request, sa_id=str(sa.id)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @patch("api.views.service_accounts.ServiceAccount") + @patch("api.views.service_accounts.Role") + @patch("api.views.service_accounts.role_has_global_access", return_value=True) + @patch("api.views.service_accounts.user_has_permission", return_value=True) + @patch("api.views.service_accounts.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.service_accounts.IsIPAllowed.has_permission", return_value=True) + def test_update_global_role_returns_400(self, _ip, _throttle, _perm, _global, mock_role, mock_sa_model): + sa = _make_service_account(org=self.org) + mock_sa_model.objects.select_related.return_value.get.return_value = sa + + admin_role = _make_role(name="Admin", org=self.org, global_access=True) + mock_role.objects.get.return_value = admin_role + + request = _build_detail_request( + "put", f"/public/v1/service-accounts/{sa.id}/", self.org, + data={"role_id": str(admin_role.id)}, + ) + response = self.view(request, sa_id=str(sa.id)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Admin" in response.data["error"] From 77e97e5cdcff0c4219ea20a095705cf6238d9efd Mon Sep 17 00:00:00 2001 From: rohan Date: Fri, 13 Mar 2026 15:59:49 +0530 Subject: [PATCH 05/20] fix: validate environments when assigning account app access Signed-off-by: rohan --- backend/api/views/service_accounts.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/api/views/service_accounts.py b/backend/api/views/service_accounts.py index 978c7d48b..b75c085f3 100644 --- a/backend/api/views/service_accounts.py +++ b/backend/api/views/service_accounts.py @@ -493,10 +493,15 @@ def put(self, request, sa_id, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) - env_ids = app_entry.get("environments", []) - if not isinstance(env_ids, list): + env_ids = app_entry.get("environments") + if env_ids is None or not isinstance(env_ids, list): return Response( - {"error": f"'environments' for app '{app_id}' must be a list of environment IDs."}, + {"error": f"Each app entry must have an 'environments' list of environment IDs."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(env_ids) == 0: + return Response( + {"error": f"'environments' for app '{app_id}' must not be empty. To revoke all access, remove the app from the list."}, status=status.HTTP_400_BAD_REQUEST, ) From c23a7eb5dd894ce5eafee9fb7175c9596b3dd28c Mon Sep 17 00:00:00 2001 From: rohan Date: Fri, 13 Mar 2026 16:17:35 +0530 Subject: [PATCH 06/20] feat: validate json permissions when creating or updating roles Signed-off-by: rohan --- backend/api/views/roles.py | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/backend/api/views/roles.py b/backend/api/views/roles.py index d77ee61ad..f57d5f101 100644 --- a/backend/api/views/roles.py +++ b/backend/api/views/roles.py @@ -19,6 +19,66 @@ logger = logging.getLogger(__name__) +# Derive valid permission classes and actions from the Owner role (which has full access to everything) +_owner = default_roles["Owner"] +VALID_ORG_PERMISSIONS = { + resource: set(actions) for resource, actions in _owner["permissions"].items() +} +VALID_APP_PERMISSIONS = { + resource: set(actions) for resource, actions in _owner["app_permissions"].items() +} + + +def _validate_permissions(permissions): + """ + Validate the shape of a permissions object against the Owner role template. + Returns (None) on success, or (error_string) on failure. + """ + if not isinstance(permissions, dict): + return "Permissions must be a JSON object." + + allowed_keys = {"permissions", "app_permissions", "global_access"} + unknown_keys = set(permissions.keys()) - allowed_keys + if unknown_keys: + return f"Unknown top-level keys: {', '.join(sorted(unknown_keys))}. Allowed keys: permissions, app_permissions, global_access." + + # Validate global_access + if "global_access" in permissions: + if not isinstance(permissions["global_access"], bool): + return "global_access must be a boolean." + + # Validate org-level permissions + org_perms = permissions.get("permissions") + if org_perms is not None: + if not isinstance(org_perms, dict): + return "permissions must be a JSON object." + for resource, actions in org_perms.items(): + if resource not in VALID_ORG_PERMISSIONS: + return f"Unknown org permission class: '{resource}'. Valid classes: {', '.join(sorted(VALID_ORG_PERMISSIONS.keys()))}." + if not isinstance(actions, list): + return f"Actions for '{resource}' must be an array." + valid_actions = VALID_ORG_PERMISSIONS[resource] + for action in actions: + if action not in valid_actions: + return f"Unknown action '{action}' for org permission class '{resource}'. Valid actions: {', '.join(sorted(valid_actions))}." + + # Validate app-level permissions + app_perms = permissions.get("app_permissions") + if app_perms is not None: + if not isinstance(app_perms, dict): + return "app_permissions must be a JSON object." + for resource, actions in app_perms.items(): + if resource not in VALID_APP_PERMISSIONS: + return f"Unknown app permission class: '{resource}'. Valid classes: {', '.join(sorted(VALID_APP_PERMISSIONS.keys()))}." + if not isinstance(actions, list): + return f"Actions for '{resource}' must be an array." + valid_actions = VALID_APP_PERMISSIONS[resource] + for action in actions: + if action not in valid_actions: + return f"Unknown action '{action}' for app permission class '{resource}'. Valid actions: {', '.join(sorted(valid_actions))}." + + return None + def _get_role_permissions(role): """Get permissions for a role — from default_roles dict if default, otherwise from the stored JSONField.""" @@ -126,6 +186,13 @@ def post(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + perm_error = _validate_permissions(permissions) + if perm_error: + return Response( + {"error": perm_error}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Optional fields description = request.data.get("description", "") if description and len(str(description)) > 500: @@ -287,6 +354,12 @@ def put(self, request, role_id, *args, **kwargs): {"error": "Permissions must be a JSON object."}, status=status.HTTP_400_BAD_REQUEST, ) + perm_error = _validate_permissions(permissions) + if perm_error: + return Response( + {"error": perm_error}, + status=status.HTTP_400_BAD_REQUEST, + ) role.permissions = permissions role.save() From aa10e2b10e8e2c764820fb4789bc177211163054 Mon Sep 17 00:00:00 2001 From: rohan Date: Sat, 14 Mar 2026 15:55:41 +0530 Subject: [PATCH 07/20] wip: audit logs Signed-off-by: rohan --- backend/api/migrations/0117_auditevent.py | 39 + backend/api/models.py | 88 ++ backend/api/utils/access/roles.py | 5 + backend/api/utils/audit_logging.py | 129 ++- backend/api/views/apps.py | 65 +- backend/api/views/audit.py | 132 +++ backend/api/views/environments.py | 62 +- backend/api/views/roles.py | 64 +- backend/api/views/service_accounts.py | 99 +- backend/backend/graphene/mutations/access.py | 135 +++ backend/backend/graphene/mutations/app.py | 120 +++ .../backend/graphene/mutations/environment.py | 126 ++- .../graphene/mutations/organisation.py | 44 +- .../graphene/mutations/service_accounts.py | 77 +- backend/backend/graphene/types.py | 27 + backend/backend/schema.py | 74 ++ backend/backend/urls.py | 2 + frontend/apollo/gql.ts | 5 + frontend/apollo/graphql.ts | 54 + frontend/apollo/schema.graphql | 33 + frontend/app/[team]/logs/page.tsx | 24 + frontend/components/layout/Sidebar.tsx | 7 + frontend/components/logs/AuditLogs.tsx | 971 ++++++++++++++++++ .../queries/organisation/getAuditLogs.gql | 41 + 24 files changed, 2414 insertions(+), 9 deletions(-) create mode 100644 backend/api/migrations/0117_auditevent.py create mode 100644 backend/api/views/audit.py create mode 100644 frontend/app/[team]/logs/page.tsx create mode 100644 frontend/components/logs/AuditLogs.tsx create mode 100644 frontend/graphql/queries/organisation/getAuditLogs.gql diff --git a/backend/api/migrations/0117_auditevent.py b/backend/api/migrations/0117_auditevent.py new file mode 100644 index 000000000..fb7b52015 --- /dev/null +++ b/backend/api/migrations/0117_auditevent.py @@ -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', '0116_app_description'), + ] + + 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')], + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index c69b94ccc..d66f600e7 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -936,6 +936,94 @@ class Meta: user_agent = models.TextField(null=True, blank=True) +class AuditEvent(models.Model): + """ + Generic audit log for organisation-level events (Apps, Environments, Roles, + Service Accounts, Members, Tokens, Network Policies). + SecretEvent remains separate for encrypted-value logging. + """ + + CREATE = "C" + READ = "R" + UPDATE = "U" + DELETE = "D" + ACCESS = "A" + EVENT_TYPES = [ + (CREATE, "Create"), + (READ, "Read"), + (UPDATE, "Update"), + (DELETE, "Delete"), + (ACCESS, "Access"), + ] + + APP = "app" + ENVIRONMENT = "env" + ROLE = "role" + SERVICE_ACCOUNT = "sa" + ORG_MEMBER = "member" + NETWORK_POLICY = "policy" + USER_TOKEN = "pat" + SA_TOKEN = "sa_token" + SERVICE_TOKEN = "svc_token" + RESOURCE_TYPES = [ + (APP, "App"), + (ENVIRONMENT, "Environment"), + (ROLE, "Role"), + (SERVICE_ACCOUNT, "ServiceAccount"), + (ORG_MEMBER, "OrganisationMember"), + (NETWORK_POLICY, "NetworkAccessPolicy"), + (USER_TOKEN, "UserToken"), + (SA_TOKEN, "ServiceAccountToken"), + (SERVICE_TOKEN, "ServiceToken"), + ] + + class Meta: + 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", + ), + ] + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="audit_events" + ) + + # What happened + event_type = models.CharField(max_length=1, choices=EVENT_TYPES) + resource_type = models.CharField(max_length=10, choices=RESOURCE_TYPES) + resource_id = models.TextField() + + # Who did it (no ForeignKey — survives entity deletion) + actor_type = models.CharField( + max_length=10, + choices=[("user", "User"), ("sa", "ServiceAccount")], + ) + actor_id = models.TextField() + actor_metadata = models.JSONField(default=dict) + + # What changed + resource_metadata = models.JSONField(default=dict) + old_values = models.JSONField(null=True, blank=True) + new_values = models.JSONField(null=True, blank=True) + description = models.TextField(blank=True, default="") + + # Request metadata + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True, default="") + + timestamp = models.DateTimeField(default=timezone.now) + + class Identity(models.Model): """ Third-party identity configuration. diff --git a/backend/api/utils/access/roles.py b/backend/api/utils/access/roles.py index 138d0e50d..4707bfc64 100644 --- a/backend/api/utils/access/roles.py +++ b/backend/api/utils/access/roles.py @@ -16,6 +16,7 @@ "Roles": ["create", "read", "update", "delete"], "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], + "Logs": ["read"], }, "app_permissions": { "Environments": ["create", "read", "update", "delete"], @@ -48,6 +49,7 @@ "Roles": ["create", "read", "update", "delete"], "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], + "Logs": ["read"], }, "app_permissions": { "Environments": ["create", "read", "update", "delete"], @@ -79,6 +81,7 @@ "Roles": ["create", "read", "update", "delete"], "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], + "Logs": ["read"], }, "app_permissions": { "Environments": ["read", "create", "update"], @@ -114,6 +117,7 @@ "update", ], "NetworkAccessPolicies": ["read"], + "Logs": ["read"], }, "app_permissions": { "Environments": ["read", "create", "update"], @@ -145,6 +149,7 @@ "Roles": ["read"], "IntegrationCredentials": ["read"], "NetworkAccessPolicies": ["read"], + "Logs": [], }, "app_permissions": { "Environments": ["read", "create", "update", "delete"], diff --git a/backend/api/utils/audit_logging.py b/backend/api/utils/audit_logging.py index e70d3d480..c82ee3923 100644 --- a/backend/api/utils/audit_logging.py +++ b/backend/api/utils/audit_logging.py @@ -1,6 +1,10 @@ +import logging + from django.utils import timezone -from api.models import SecretEvent +from api.models import AuditEvent, SecretEvent + +logger = logging.getLogger(__name__) def log_secret_event( @@ -41,3 +45,126 @@ def log_secret_event( ) event.tags.set(secret.tags.all()) + + +def log_audit_event( + organisation, + event_type, + resource_type, + resource_id, + actor_type, + actor_id, + actor_metadata=None, + resource_metadata=None, + old_values=None, + new_values=None, + description="", + ip_address=None, + user_agent="", +): + """ + Log a generic audit event. Fire-and-forget — never raises. + """ + try: + AuditEvent.objects.create( + organisation=organisation, + event_type=event_type, + resource_type=resource_type, + resource_id=str(resource_id), + actor_type=actor_type, + actor_id=str(actor_id), + actor_metadata=actor_metadata or {}, + resource_metadata=resource_metadata or {}, + old_values=old_values, + new_values=new_values, + description=description, + ip_address=ip_address, + user_agent=user_agent or "", + ) + except Exception: + logger.exception("Failed to write audit event: %s %s %s", event_type, resource_type, resource_id) + + +def get_actor_info(request): + """ + Extract actor info from request.auth (REST views). + Returns (actor_type, actor_id, actor_metadata). + """ + auth = request.auth + if auth.get("auth_type") == "User": + org_member = auth["org_member"] + return ( + "user", + str(org_member.id), + { + "email": getattr(org_member.user, "email", ""), + "username": getattr(org_member.user, "username", ""), + }, + ) + elif auth.get("auth_type") == "ServiceAccount": + sa = auth["service_account"] + return ( + "sa", + str(sa.id), + {"name": sa.name}, + ) + return ("user", "", {}) + + +def get_actor_info_from_graphql(info): + """ + Extract actor info from GraphQL info.context. + GraphQL mutations are user-only today. + Returns (actor_type, actor_id, actor_metadata). + """ + user = info.context.user + if hasattr(user, "userId"): + # user is a CustomUser; find the org_member from context if available + from api.models import OrganisationMember + + # Try to get org_member from the info context or look it up + org_member = getattr(info.context, "_org_member", None) + if org_member is None: + try: + org_member = OrganisationMember.objects.filter( + user=user, deleted_at=None + ).first() + except Exception: + pass + if org_member: + return ( + "user", + str(org_member.id), + { + "email": getattr(user, "email", ""), + "username": getattr(user, "username", ""), + }, + ) + return ("user", "", {}) + + +def build_change_values(instance, fields, new_data): + """ + Compare model instance fields against incoming new_data dict. + Returns (old_values, new_values) with only changed fields. + Returns (None, None) if nothing changed. + """ + old = {} + new = {} + for field in fields: + old_val = getattr(instance, field, None) + new_val = new_data.get(field) + if new_val is not None and old_val != new_val: + # Serialize to JSON-safe types + old[field] = _serialize_value(old_val) + new[field] = _serialize_value(new_val) + return (old or None, new or None) + + +def _serialize_value(val): + """Make a value JSON-serializable.""" + if val is None: + return None + if isinstance(val, (str, int, float, bool, list, dict)): + return val + return str(val) diff --git a/backend/api/views/apps.py b/backend/api/views/apps.py index f955b320d..e3c81d50b 100644 --- a/backend/api/views/apps.py +++ b/backend/api/views/apps.py @@ -12,8 +12,9 @@ split_secret_hex, wrap_share_hex, ) +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 +from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta from api.throttling import PlanBasedRateThrottle from api.utils.access.middleware import IsIPAllowed from backend.quotas import can_add_app, can_use_custom_envs @@ -245,6 +246,24 @@ def post(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="C", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": app.name}, + new_values={"name": app.name, "description": app.description or ""}, + description=f"Created app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + serializer = AppSerializer(app) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -303,6 +322,10 @@ def put(self, request, app_id, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + old_values, new_values = build_change_values( + app, ["name", "description"], request.data + ) + if name is not None: if not name or str(name).strip() == "": return Response( @@ -326,11 +349,33 @@ def put(self, request, app_id, *args, **kwargs): app.save() + # Audit log + if old_values: + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=app.organisation, + event_type="U", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": app.name}, + old_values=old_values, + new_values=new_values, + description=f"Updated app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + serializer = AppSerializer(app) return Response(serializer.data, status=status.HTTP_200_OK) def delete(self, request, app_id, *args, **kwargs): app = request.auth["app"] + app_name = app.name + org = app.organisation if CLOUD_HOSTED: from backend.api.kv import delete as kv_delete, purge as kv_purge @@ -349,4 +394,22 @@ def delete(self, request, app_id, *args, **kwargs): app.save() app.delete() + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="D", + resource_type="app", + resource_id=app_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": app_name}, + old_values={"name": app_name}, + description=f"Deleted app '{app_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/views/audit.py b/backend/api/views/audit.py new file mode 100644 index 000000000..76e99a9f5 --- /dev/null +++ b/backend/api/views/audit.py @@ -0,0 +1,132 @@ +from datetime import datetime, timedelta + +from api.auth import PhaseTokenAuthentication +from api.models import AuditEvent +from api.utils.access.permissions import user_has_permission +from api.utils.database import get_approximate_count +from api.utils.rest import METHOD_TO_ACTION +from api.throttling import PlanBasedRateThrottle +from api.utils.access.middleware import IsIPAllowed + +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework import status +from djangorestframework_camel_case.render import CamelCaseJSONRenderer +from django.utils import timezone + + +class PublicAuditLogsView(APIView): + """Query audit logs for an organisation.""" + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def _get_org(self, request): + if request.auth.get("organisation"): + return request.auth["organisation"] + if request.auth.get("app"): + return request.auth["app"].organisation + raise PermissionDenied("Could not resolve organisation from request.") + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + account = None + is_sa = False + if request.auth["auth_type"] == "User": + account = request.auth["org_member"].user + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + + if account is not None: + org = self._get_org(request) + if not user_has_permission(account, "read", "Logs", org, False, is_sa): + raise PermissionDenied("You don't have permission to view audit logs.") + + def get(self, request, *args, **kwargs): + org = self._get_org(request) + + # Parse query parameters + start = request.query_params.get("start") + end = request.query_params.get("end") + resource_type = request.query_params.get("resource_type") + resource_id = request.query_params.get("resource_id") + event_types = request.query_params.getlist("event_types") + actor_id = request.query_params.get("actor_id") + + try: + limit = min(max(1, int(request.query_params.get("limit", 50))), 200) + except (ValueError, TypeError): + limit = 50 + + try: + offset = max(0, int(request.query_params.get("offset", 0))) + except (ValueError, TypeError): + offset = 0 + + # Time range + now = timezone.now() + if end: + try: + end_dt = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc) + except (ValueError, TypeError, OSError): + end_dt = now + else: + end_dt = now + + if start: + try: + start_dt = datetime.fromtimestamp(int(start) / 1000, tz=timezone.utc) + except (ValueError, TypeError, OSError): + start_dt = end_dt - timedelta(days=30) + else: + start_dt = end_dt - timedelta(days=30) + + # Build filter + filters = { + "organisation": org, + "timestamp__gte": start_dt, + "timestamp__lte": end_dt, + } + if resource_type: + filters["resource_type"] = resource_type + if resource_id: + filters["resource_id"] = resource_id + if event_types: + filters["event_type__in"] = event_types + if actor_id: + filters["actor_id"] = actor_id + + qs = AuditEvent.objects.filter(**filters).order_by("-timestamp") + count = get_approximate_count(qs) + events = qs[offset : offset + limit] + + logs = [ + { + "id": str(e.id), + "event_type": e.event_type, + "resource_type": e.resource_type, + "resource_id": e.resource_id, + "actor_type": e.actor_type, + "actor_id": e.actor_id, + "actor_metadata": e.actor_metadata, + "resource_metadata": e.resource_metadata, + "old_values": e.old_values, + "new_values": e.new_values, + "description": e.description, + "ip_address": str(e.ip_address) if e.ip_address else None, + "user_agent": e.user_agent, + "timestamp": e.timestamp, + } + for e in events + ] + + return Response( + {"logs": logs, "count": count}, + status=status.HTTP_200_OK, + ) diff --git a/backend/api/views/environments.py b/backend/api/views/environments.py index b243351e5..5dbd354cf 100644 --- a/backend/api/views/environments.py +++ b/backend/api/views/environments.py @@ -8,8 +8,9 @@ user_can_access_environment, service_account_can_access_environment, ) +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 +from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta from api.throttling import PlanBasedRateThrottle from api.utils.access.middleware import IsIPAllowed from backend.quotas import can_add_environment, can_use_custom_envs @@ -132,6 +133,24 @@ def post(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=app.organisation, + event_type="C", + resource_type="env", + resource_id=environment.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": environment.name, "app_id": str(app.id), "app_name": app.name}, + new_values={"name": environment.name, "env_type": environment.env_type}, + description=f"Created environment '{environment.name}' in app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + serializer = EnvironmentSerializer(environment) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -264,9 +283,30 @@ def put(self, request, env_id, *args, **kwargs): status=status.HTTP_409_CONFLICT, ) + old_name = env.name env.name = name env.save() + # Audit log + if old_name != name: + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=app.organisation, + event_type="U", + resource_type="env", + resource_id=env.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": name, "app_id": str(app.id), "app_name": app.name}, + old_values={"name": old_name}, + new_values={"name": name}, + description=f"Renamed environment '{old_name}' to '{name}' in app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + serializer = EnvironmentSerializer(env) return Response(serializer.data, status=status.HTTP_200_OK) @@ -287,5 +327,25 @@ def delete(self, request, env_id, *args, **kwargs): status=status.HTTP_403_FORBIDDEN, ) + env_name = env.name env.delete() + + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="D", + resource_type="env", + resource_id=env_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": env_name, "app_id": str(app.id), "app_name": app.name}, + old_values={"name": env_name}, + description=f"Deleted environment '{env_name}' from app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/views/roles.py b/backend/api/views/roles.py index f57d5f101..593765ad3 100644 --- a/backend/api/views/roles.py +++ b/backend/api/views/roles.py @@ -6,7 +6,8 @@ from api.models import Organisation, OrganisationMember, Role, ServiceAccount from api.utils.access.permissions import user_has_permission from api.utils.access.roles import default_roles -from api.utils.rest import METHOD_TO_ACTION +from api.utils.audit_logging import log_audit_event, get_actor_info, build_change_values +from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta from api.throttling import PlanBasedRateThrottle from api.utils.access.middleware import IsIPAllowed @@ -216,6 +217,24 @@ def post(self, request, *args, **kwargs): permissions=permissions, ) + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="C", + resource_type="role", + resource_id=role.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": role.name}, + new_values={"name": role.name, "permissions": permissions}, + description=f"Created role '{role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response( _serialize_role(role, include_permissions=True), status=status.HTTP_201_CREATED, @@ -302,6 +321,10 @@ def put(self, request, role_id, *args, **kwargs): color = request.data.get("color") permissions = request.data.get("permissions") + old_values, new_values = build_change_values( + role, ["name", "description", "color", "permissions"], request.data + ) + if name is None and description is None and color is None and permissions is None: return Response( {"error": "At least one field must be provided."}, @@ -364,6 +387,26 @@ def put(self, request, role_id, *args, **kwargs): role.save() + # Audit log + if old_values: + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="U", + resource_type="role", + resource_id=role.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": role.name}, + old_values=old_values, + new_values=new_values, + description=f"Updated role '{role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response( _serialize_role(role, include_permissions=True), status=status.HTTP_200_OK, @@ -399,6 +442,25 @@ def delete(self, request, role_id, *args, **kwargs): status=status.HTTP_409_CONFLICT, ) + role_name = role.name role.delete() + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="D", + resource_type="role", + resource_id=role_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": role_name}, + old_values={"name": role_name}, + description=f"Deleted role '{role_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/views/service_accounts.py b/backend/api/views/service_accounts.py index b75c085f3..cb361c6e7 100644 --- a/backend/api/views/service_accounts.py +++ b/backend/api/views/service_accounts.py @@ -28,7 +28,8 @@ wrap_share_hex, ) from api.utils.environments import _ed25519_pk_to_curve25519, _wrap_env_secrets_for_key -from api.utils.rest import METHOD_TO_ACTION +from api.utils.audit_logging import log_audit_event, get_actor_info, build_change_values +from api.utils.rest import METHOD_TO_ACTION, get_resolver_request_meta from api.utils.service_accounts import generate_server_managed_sa_keys from api.throttling import PlanBasedRateThrottle from api.utils.access.middleware import IsIPAllowed @@ -248,6 +249,24 @@ def post(self, request, *args, **kwargs): full_token = f"pss_service:v2:{token_value}:{kx_pub}:{share_a}:{wrap_key}" bearer_token = f"ServiceAccount {token_value}" + # Audit log — SA creation + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="C", + resource_type="sa", + resource_id=sa.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": sa.name}, + new_values={"name": sa.name, "role": role.name}, + description=f"Created service account '{sa.name}' with role '{role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + response_data = _serialize_sa(sa) response_data["token"] = full_token response_data["bearer_token"] = bearer_token @@ -319,6 +338,9 @@ def put(self, request, sa_id, *args, **kwargs): status=status.HTTP_404_NOT_FOUND, ) + old_name = sa.name + old_role_name = sa.role.name if sa.role else None + name = request.data.get("name") role_id = request.data.get("role_id") @@ -357,6 +379,35 @@ def put(self, request, sa_id, *args, **kwargs): sa.role = role sa.save() + + # Audit log + old_vals = {} + new_vals = {} + if name is not None and old_name != sa.name: + old_vals["name"] = old_name + new_vals["name"] = sa.name + if role_id is not None and old_role_name != (sa.role.name if sa.role else None): + old_vals["role"] = old_role_name + new_vals["role"] = sa.role.name if sa.role else None + if old_vals: + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=sa.organisation, + event_type="U", + resource_type="sa", + resource_id=sa.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": sa.name}, + old_values=old_vals, + new_values=new_vals, + description=f"Updated service account '{sa.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response(_serialize_sa_detail(sa), status=status.HTTP_200_OK) def delete(self, request, sa_id, *args, **kwargs): @@ -367,12 +418,32 @@ def delete(self, request, sa_id, *args, **kwargs): status=status.HTTP_404_NOT_FOUND, ) + sa_name = sa.name + org = sa.organisation sa.delete() if CLOUD_HOSTED: from ee.billing.stripe import update_stripe_subscription_seats - update_stripe_subscription_seats(sa.organisation) + update_stripe_subscription_seats(org) + + # Audit log + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="D", + resource_type="sa", + resource_id=sa_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": sa_name}, + old_values={"name": sa_name}, + description=f"Deleted service account '{sa_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -629,4 +700,28 @@ def put(self, request, sa_id, *args, **kwargs): if new_env_keys: EnvironmentKey.objects.bulk_create(new_env_keys) + # Audit log — access change + access_changes = {} + if apps_to_add: + access_changes["apps_granted"] = list(apps_to_add) + if apps_to_remove: + access_changes["apps_revoked"] = list(apps_to_remove) + if access_changes or desired_env_map: + actor_type, actor_id, actor_meta = get_actor_info(request) + ip_address, user_agent = get_resolver_request_meta(request) + log_audit_event( + organisation=org, + event_type="A", + resource_type="sa", + resource_id=sa.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"name": sa.name}, + new_values=access_changes or {"access_updated": True}, + description=f"Updated access for service account '{sa.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return Response(_serialize_sa_detail(sa), status=status.HTTP_200_OK) diff --git a/backend/backend/graphene/mutations/access.py b/backend/backend/graphene/mutations/access.py index cefa43985..301556b52 100644 --- a/backend/backend/graphene/mutations/access.py +++ b/backend/backend/graphene/mutations/access.py @@ -6,6 +6,8 @@ ServiceAccount, ) from api.utils.access.permissions import user_has_permission +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql +from api.utils.rest import get_resolver_request_meta from backend.graphene.types import NetworkAccessPolicyType, RoleType, IdentityType from api.models import Identity from django.utils import timezone @@ -49,6 +51,22 @@ def mutate(cls, root, info, name, description, color, permissions, organisation_ permissions=permissions, ) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="C", + resource_type="role", + resource_id=role.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + description=f"Created role '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateCustomRoleMutation(role=role) @@ -84,12 +102,32 @@ def mutate(cls, root, info, id, name, description, color, permissions): ): raise GraphQLError("A role with this name already exists!") + old_values = {"name": role.name, "description": role.description, "color": role.color} + role.name = name role.description = description role.color = color role.permissions = permissions role.save() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=role.organisation, + event_type="U", + resource_type="role", + resource_id=role.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + old_values=old_values, + new_values={"name": name, "description": description, "color": color}, + description=f"Updated role '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return UpdateCustomRoleMutation(role=role) @@ -116,8 +154,28 @@ def mutate(cls, root, info, id): if role.is_default: raise GraphQLError("This is a default role and cannot be deleted!") + role_name = role.name + role_id = role.id + role_org = role.organisation + role.delete() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=role_org, + event_type="D", + resource_type="role", + resource_id=role_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": role_name}, + description=f"Deleted role '{role_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteCustomRoleMutation(ok=True) @@ -152,6 +210,22 @@ def mutate(cls, root, info, name, allowed_ips, is_global, organisation_id): updated_by=org_member, ) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="C", + resource_type="policy", + resource_id=policy.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + description=f"Created network access policy '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateNetworkAccessPolicyMutation(network_access_policy=policy) @@ -172,6 +246,9 @@ class Arguments: def mutate(cls, root, info, policy_inputs): user = info.context.user + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + for policy_input in policy_inputs: policy = NetworkAccessPolicy.objects.get(id=policy_input.id) org_member = OrganisationMember.objects.get( @@ -184,6 +261,8 @@ def mutate(cls, root, info, policy_inputs): "You don't have the permissions required to update Network Access Policies in this organisation" ) + old_values = {"name": policy.name, "allowed_ips": policy.allowed_ips, "is_global": policy.is_global} + if policy_input.name is not None: policy.name = policy_input.name @@ -197,6 +276,22 @@ def mutate(cls, root, info, policy_inputs): policy.save() + log_audit_event( + organisation=policy.organisation, + event_type="U", + resource_type="policy", + resource_id=policy.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": policy.name}, + old_values=old_values, + new_values={"name": policy.name, "allowed_ips": policy.allowed_ips, "is_global": policy.is_global}, + description=f"Updated network access policy '{policy.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return UpdateNetworkAccessPolicyMutation(network_access_policy=policy) @@ -218,8 +313,28 @@ def mutate(cls, root, info, id): "You don't have the permissions required to delete Network Access Policies in this organisation" ) + policy_name = policy.name + policy_id = policy.id + policy_org = policy.organisation + policy.delete() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=policy_org, + event_type="D", + resource_type="policy", + resource_id=policy_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": policy_name}, + description=f"Deleted network access policy '{policy_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteNetworkAccessPolicyMutation(ok=True) @@ -433,6 +548,10 @@ def mutate(cls, root, info, account_inputs, organisation_id): "You don't have the permissions required to delete Network Access Policies in this organisation" ) + org = Organisation.objects.get(id=organisation_id) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + for account_input in account_inputs: account_filter = { "organisation_id": organisation_id, @@ -449,4 +568,20 @@ def mutate(cls, root, info, account_inputs, organisation_id): NetworkAccessPolicy.objects.filter(id__in=account_input.policy_ids) ) + resource_type = "member" if account_input.account_type == AccountTypeEnum.USER else "sa" + log_audit_event( + organisation=org, + event_type="A", + resource_type=resource_type, + resource_id=account_input.account_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"account_type": account_input.account_type.value}, + new_values={"policy_ids": list(account_input.policy_ids) if account_input.policy_ids else []}, + description=f"Updated network access policies for {resource_type} '{account_input.account_id}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return UpdateAccountNetworkAccessPolicies(ok=True) diff --git a/backend/backend/graphene/mutations/app.py b/backend/backend/graphene/mutations/app.py index d9eae6450..de9e74fdb 100644 --- a/backend/backend/graphene/mutations/app.py +++ b/backend/backend/graphene/mutations/app.py @@ -16,6 +16,8 @@ ServiceAccount, ) from backend.graphene.types import AppType, MemberType +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql +from api.utils.rest import get_resolver_request_meta from django.conf import settings from django.db.models import Q @@ -85,6 +87,22 @@ def mutate( for admin in org_admins: admin.apps.add(app) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="C", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + description=f"Created app '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateAppMutation(app=app) @@ -144,6 +162,9 @@ def mutate(cls, root, info, id, name=None, description=None): ): raise GraphQLError("You don't have permission to update Apps") + old_name = app.name + old_description = app.description + if name is not None: # Validate name is not blank if not name or name.strip() == "": @@ -163,6 +184,33 @@ def mutate(cls, root, info, id, name=None, description=None): app.save() + old_values = {} + new_values = {} + if name is not None and name != old_name: + old_values["name"] = old_name + new_values["name"] = name + if description is not None and description != old_description: + old_values["description"] = old_description + new_values["description"] = description + + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=app.organisation, + event_type="U", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": app.name}, + old_values=old_values or None, + new_values=new_values or None, + description=f"Updated app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return UpdateAppInfoMutation(app=app) @@ -197,10 +245,30 @@ def mutate(cls, root, info, id): if not deleted or not purged: raise GraphQLError("Failed to delete app keys. Please try again.") + app_name = app.name + app_id = app.id + app_org = app.organisation + app.wrapped_key_share = "" app.save() app.delete() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=app_org, + event_type="D", + resource_type="app", + resource_id=app_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": app_name}, + description=f"Deleted app '{app_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteAppMutation(ok=True) @@ -266,6 +334,24 @@ def mutate(cls, root, info, app_id, members): EnvironmentKey.objects.update_or_create(**condition, defaults=defaults) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + member_ids = [m.member_id for m in members] + log_audit_event( + organisation=app.organisation, + event_type="A", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": app.name}, + new_values={"members_added": member_ids}, + description=f"Added {len(members)} member(s) to app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return BulkAddAppMembersMutation(app=app) @@ -323,6 +409,23 @@ def mutate( EnvironmentKey.objects.update_or_create(**condition, defaults=defaults) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=app.organisation, + event_type="A", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": app.name}, + new_values={"member_id": str(member_id), "member_type": member_type.value if hasattr(member_type, 'value') else str(member_type)}, + description=f"Added member to app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return AddAppMemberMutation(app=app) @@ -381,4 +484,21 @@ def mutate(cls, root, info, member_id, app_id, member_type=MemberType.USER): environment__app=app, service_account_id=member_id ).delete() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=app.organisation, + event_type="A", + resource_type="app", + resource_id=app.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": app.name}, + old_values={"member_id": str(member_id), "member_type": member_type.value if hasattr(member_type, 'value') else str(member_type)}, + description=f"Removed member from app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return RemoveAppMemberMutation(app=app) diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index 39bf61941..123d9d21f 100644 --- a/backend/backend/graphene/mutations/environment.py +++ b/backend/backend/graphene/mutations/environment.py @@ -10,7 +10,7 @@ user_has_permission, user_is_org_member, ) -from api.utils.audit_logging import log_secret_event +from api.utils.audit_logging import log_secret_event, log_audit_event, get_actor_info_from_graphql from api.utils.secrets import create_environment_folder_structure, normalize_path_string from backend.quotas import can_add_environment, can_use_custom_envs import graphene @@ -190,6 +190,22 @@ def mutate( wrapped_salt=wrapped_salt, ) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=app.organisation, + event_type="C", + resource_type="env", + resource_id=environment.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": environment_data.name, "app": app.name}, + description=f"Created environment '{environment_data.name}' in app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateEnvironmentMutation(environment=environment) @@ -229,10 +245,29 @@ def mutate(cls, root, info, environment_id, name): raise GraphQLError( "An Environment with this name already exists in this App!" ) + old_name = environment.name environment.name = name environment.updated_at = timezone.now() environment.save() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="U", + resource_type="env", + resource_id=environment.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + old_values={"name": old_name}, + new_values={"name": name}, + description=f"Renamed environment '{old_name}' to '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return RenameEnvironmentMutation(environment=environment) @@ -258,8 +293,27 @@ def mutate(cls, root, info, environment_id): "Your Organisation doesn't have access to Custom Environments" ) + env_name = environment.name + env_id = environment.id + environment.delete() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="D", + resource_type="env", + resource_id=env_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": env_name}, + description=f"Deleted environment '{env_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteEnvironmentMutation(ok=True) @@ -489,6 +543,22 @@ def mutate( expires_at=expires_at, ) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org_member.organisation, + event_type="C", + resource_type="pat", + resource_id=user_token.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + description=f"Created personal access token '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateUserTokenMutation(user_token=user_token, ok=True) else: @@ -508,9 +578,28 @@ def mutate(cls, root, info, token_id): org = token.user.organisation if user_is_org_member(user.userId, org.id): + token_name = token.name + token_id = token.id + token.deleted_at = timezone.now() token.save() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="D", + resource_type="pat", + resource_id=token_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": token_name}, + description=f"Deleted personal access token '{token_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteUserTokenMutation(ok=True) else: raise GraphQLError("You don't have permission to perform this action") @@ -584,6 +673,22 @@ def mutate( service_token.keys.set(env_keys) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=app.organisation, + event_type="C", + resource_type="svc_token", + resource_id=service_token.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name, "app": app.name}, + description=f"Created service token '{name}' for app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateServiceTokenMutation(service_token=service_token) @@ -602,9 +707,28 @@ def mutate(cls, root, info, token_id): if not user_has_permission(info.context.user, "delete", "Tokens", org, True): raise GraphQLError("You don't have permission to delete Tokens in this App") + token_name = token.name + token_id = token.id + token.deleted_at = timezone.now() token.save() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="D", + resource_type="svc_token", + resource_id=token_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": token_name}, + description=f"Deleted service token '{token_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteServiceTokenMutation(ok=True) diff --git a/backend/backend/graphene/mutations/organisation.py b/backend/backend/graphene/mutations/organisation.py index 422621ee0..f117ad058 100644 --- a/backend/backend/graphene/mutations/organisation.py +++ b/backend/backend/graphene/mutations/organisation.py @@ -24,6 +24,8 @@ OrganisationMemberType, OrganisationType, ) +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql +from api.utils.rest import get_resolver_request_meta from datetime import timedelta from django.utils import timezone from django.conf import settings @@ -314,12 +316,32 @@ def mutate(cls, root, info, member_id): if org_member.user == info.context.user: raise GraphQLError("You can't remove yourself from an organisation") + member_id_val = org_member.id + member_org = org_member.organisation + member_email = getattr(org_member.user, "email", "") + org_member.delete() if settings.APP_HOST == "cloud": from ee.billing.stripe import update_stripe_subscription_seats - update_stripe_subscription_seats(org_member.organisation) + update_stripe_subscription_seats(member_org) + + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=member_org, + event_type="D", + resource_type="member", + resource_id=member_id_val, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"email": member_email}, + description=f"Deleted organisation member '{member_email}'", + ip_address=ip_address, + user_agent=user_agent, + ) return DeleteOrganisationMemberMutation(ok=True) @@ -362,9 +384,29 @@ def mutate(cls, root, info, member_id, role_id): if new_role.name.lower() == "owner": raise GraphQLError("You cannot set this user as the organisation owner") + old_role_name = org_member.role.name + org_member.role = new_role org_member.save() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org_member.organisation, + event_type="U", + resource_type="member", + resource_id=org_member.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"email": getattr(org_member.user, "email", "")}, + old_values={"role": old_role_name}, + new_values={"role": new_role.name}, + description=f"Updated member role from '{old_role_name}' to '{new_role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return UpdateOrganisationMemberRole(org_member=org_member) diff --git a/backend/backend/graphene/mutations/service_accounts.py b/backend/backend/graphene/mutations/service_accounts.py index cc91d8ba3..f437632fa 100644 --- a/backend/backend/graphene/mutations/service_accounts.py +++ b/backend/backend/graphene/mutations/service_accounts.py @@ -15,6 +15,8 @@ user_has_permission, user_is_org_member, ) +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql +from api.utils.rest import get_resolver_request_meta from backend.graphene.types import ServiceAccountTokenType, ServiceAccountType from datetime import datetime from django.conf import settings @@ -93,6 +95,22 @@ def mutate( update_stripe_subscription_seats(org) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=org, + event_type="C", + resource_type="sa", + resource_id=service_account.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name}, + description=f"Created service account '{name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateServiceAccountMutation(service_account=service_account) @@ -254,12 +272,32 @@ def mutate(cls, root, info, service_account_id): "You don't have the permissions required to delete Service Accounts in this organisation" ) + sa_name = service_account.name + sa_id = service_account.id + sa_org = service_account.organisation + service_account.delete() if settings.APP_HOST == "cloud": from ee.billing.stripe import update_stripe_subscription_seats - update_stripe_subscription_seats(service_account.organisation) + update_stripe_subscription_seats(sa_org) + + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=sa_org, + event_type="D", + resource_type="sa", + resource_id=sa_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": sa_name}, + description=f"Deleted service account '{sa_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) return DeleteServiceAccountMutation(ok=True) @@ -315,6 +353,22 @@ def mutate( expires_at=expires_at, ) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=service_account.organisation, + event_type="C", + resource_type="sa_token", + resource_id=token.id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": name, "service_account": service_account.name}, + description=f"Created service account token '{name}' for '{service_account.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return CreateServiceAccountTokenMutation(token=token) @@ -336,6 +390,27 @@ def mutate(cls, root, info, token_id): "You don't have the permissions required to delete Service Tokens in this organisation" ) + token_name = token.name + token_id = token.id + token_org = token.service_account.organisation + sa_name = token.service_account.name + token.delete() + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + log_audit_event( + organisation=token_org, + event_type="D", + resource_type="sa_token", + resource_id=token_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": token_name, "service_account": sa_name}, + description=f"Deleted service account token '{token_name}' from '{sa_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteServiceAccountTokenMutation(ok=True) diff --git a/backend/backend/graphene/types.py b/backend/backend/graphene/types.py index 5c72d026d..d8a16ff24 100644 --- a/backend/backend/graphene/types.py +++ b/backend/backend/graphene/types.py @@ -13,6 +13,7 @@ from graphene_django import DjangoObjectType from api.models import ( ActivatedPhaseLicense, + AuditEvent, CustomUser, DynamicSecret, Environment, @@ -964,6 +965,32 @@ class SecretLogsResponseType(ObjectType): count = graphene.Int() +class AuditEventType(DjangoObjectType): + class Meta: + model = AuditEvent + fields = ( + "id", + "event_type", + "resource_type", + "resource_id", + "actor_type", + "actor_id", + "actor_metadata", + "resource_metadata", + "old_values", + "new_values", + "description", + "ip_address", + "user_agent", + "timestamp", + ) + + +class AuditLogsResponseType(ObjectType): + logs = graphene.List(AuditEventType) + count = graphene.Int() + + class LockboxType(DjangoObjectType): class Meta: model = Lockbox diff --git a/backend/backend/schema.py b/backend/backend/schema.py index 8c52abc8c..760c21935 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -5,6 +5,7 @@ from api.utils.syncing.gitlab.main import GitLabGroupType, GitLabProjectType from api.utils.syncing.railway.main import RailwayProjectType from api.utils.syncing.render.main import RenderEnvGroupType, RenderServiceType +from api.models import AuditEvent from api.utils.database import get_approximate_count from ee.integrations.secrets.dynamic.graphene.mutations import ( DeleteDynamicSecretMutation, @@ -187,6 +188,8 @@ from .graphene.types import ( ActivatedPhaseLicenseType, AppType, + AuditEventType, + AuditLogsResponseType, ChartDataPointType, EnvironmentKeyType, EnvironmentSyncType, @@ -311,6 +314,19 @@ class Query(graphene.ObjectType): environment_id=graphene.ID(), ) + audit_logs = graphene.Field( + AuditLogsResponseType, + organisation_id=graphene.ID(required=True), + start=graphene.BigInt(), + end=graphene.BigInt(), + resource_type=graphene.String(), + resource_id=graphene.ID(), + event_types=graphene.List(graphene.String), + actor_id=graphene.ID(), + offset=graphene.Int(), + limit=graphene.Int(), + ) + app_activity_chart = graphene.List( ChartDataPointType, app_id=graphene.ID(), @@ -832,6 +848,64 @@ def resolve_kms_logs(root, info, app_id, start=0, end=0): return SecretLogsResponseType(logs=kms_logs, count=count) + def resolve_audit_logs( + root, + info, + organisation_id, + start=0, + end=0, + resource_type=None, + resource_id=None, + event_types=None, + actor_id=None, + offset=0, + limit=50, + ): + user = info.context.user + + org = Organisation.objects.get(id=organisation_id) + org_member = OrganisationMember.objects.get( + user=user, organisation=org, deleted_at=None + ) + + if not user_has_permission(user, "read", "Logs", org, False): + raise GraphQLError("You don't have permission to view audit logs.") + + # Clamp limit + limit = min(max(1, limit), 200) + offset = max(0, offset) + + # Time range + if end == 0: + end_dt = timezone.now() + else: + end_dt = datetime.fromtimestamp(end / 1000, tz=timezone.utc) + if start == 0: + start_dt = end_dt - timedelta(days=30) + else: + start_dt = datetime.fromtimestamp(start / 1000, tz=timezone.utc) + + # Build filter + filters = { + "organisation": org, + "timestamp__gte": start_dt, + "timestamp__lte": end_dt, + } + if resource_type: + filters["resource_type"] = resource_type + if resource_id: + filters["resource_id"] = str(resource_id) + if event_types: + filters["event_type__in"] = event_types + if actor_id: + filters["actor_id"] = str(actor_id) + + qs = AuditEvent.objects.filter(**filters).order_by("-timestamp") + count = get_approximate_count(qs) + logs = qs[offset : offset + limit] + + return AuditLogsResponseType(logs=logs, count=count) + def resolve_secret_logs( root, info, diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 2c49992c3..2e4d0843e 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -13,6 +13,7 @@ PublicServiceAccountAccessView, ) from api.views.roles import PublicRolesView, PublicRoleDetailView +from api.views.audit import PublicAuditLogsView from api.views.auth import ( logout_view, health_check, @@ -61,6 +62,7 @@ path("public/v1/service-accounts//access/", PublicServiceAccountAccessView.as_view()), path("public/v1/roles/", PublicRolesView.as_view()), path("public/v1/roles//", PublicRoleDetailView.as_view()), + path("public/v1/audit-logs/", PublicAuditLogsView.as_view()), path( "public/v1/secrets/dynamic/", include("ee.integrations.secrets.dynamic.rest.urls"), diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 3c5406165..6b84eecca 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -121,6 +121,7 @@ const documents = { "query GetIdentityProviders {\n identityProviders {\n id\n name\n description\n iconId\n supported\n }\n}": types.GetIdentityProvidersDocument, "query GetOrganisationIdentities($organisationId: ID!) {\n identities(organisationId: $organisationId) {\n id\n provider\n name\n description\n config {\n ... on AwsIamConfigType {\n trustedPrincipals\n signatureTtlSeconds\n stsEndpoint\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n createdAt\n }\n}": types.GetOrganisationIdentitiesDocument, "query CheckOrganisationNameAvailability($name: String!) {\n organisationNameAvailable(name: $name)\n}": types.CheckOrganisationNameAvailabilityDocument, + "query GetAuditLogs($organisationId: ID!, $start: BigInt, $end: BigInt, $resourceType: String, $resourceId: ID, $eventTypes: [String], $actorId: ID, $offset: Int, $limit: Int) {\n auditLogs(\n organisationId: $organisationId\n start: $start\n end: $end\n resourceType: $resourceType\n resourceId: $resourceId\n eventTypes: $eventTypes\n actorId: $actorId\n offset: $offset\n limit: $limit\n ) {\n logs {\n id\n eventType\n resourceType\n resourceId\n actorType\n actorId\n actorMetadata\n resourceMetadata\n oldValues\n newValues\n description\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}": types.GetAuditLogsDocument, "query GetGlobalAccessUsers($organisationId: ID!) {\n organisationGlobalAccessUsers(organisationId: $organisationId) {\n id\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": types.GetGlobalAccessUsersDocument, "query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n inviteeEmail\n role {\n id\n name\n description\n color\n }\n }\n}": types.GetInvitesDocument, "query GetLicenseData {\n license {\n id\n customerName\n organisationName\n expiresAt\n plan\n seats\n isActivated\n organisationOwner {\n fullName\n email\n }\n }\n}": types.GetLicenseDataDocument, @@ -618,6 +619,10 @@ export function graphql(source: "query GetOrganisationIdentities($organisationId * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query CheckOrganisationNameAvailability($name: String!) {\n organisationNameAvailable(name: $name)\n}"): (typeof documents)["query CheckOrganisationNameAvailability($name: String!) {\n organisationNameAvailable(name: $name)\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAuditLogs($organisationId: ID!, $start: BigInt, $end: BigInt, $resourceType: String, $resourceId: ID, $eventTypes: [String], $actorId: ID, $offset: Int, $limit: Int) {\n auditLogs(\n organisationId: $organisationId\n start: $start\n end: $end\n resourceType: $resourceType\n resourceId: $resourceId\n eventTypes: $eventTypes\n actorId: $actorId\n offset: $offset\n limit: $limit\n ) {\n logs {\n id\n eventType\n resourceType\n resourceId\n actorType\n actorId\n actorMetadata\n resourceMetadata\n oldValues\n newValues\n description\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}"): (typeof documents)["query GetAuditLogs($organisationId: ID!, $start: BigInt, $end: BigInt, $resourceType: String, $resourceId: ID, $eventTypes: [String], $actorId: ID, $offset: Int, $limit: Int) {\n auditLogs(\n organisationId: $organisationId\n start: $start\n end: $end\n resourceType: $resourceType\n resourceId: $resourceId\n eventTypes: $eventTypes\n actorId: $actorId\n offset: $offset\n limit: $limit\n ) {\n logs {\n id\n eventType\n resourceType\n resourceId\n actorType\n actorId\n actorMetadata\n resourceMetadata\n oldValues\n newValues\n description\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index 5159ec8c3..92b05a060 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -253,6 +253,30 @@ export type AppType = { wrappedKeyShare: Scalars['String']['output']; }; +export type AuditEventType = { + __typename?: 'AuditEventType'; + actorId: Scalars['String']['output']; + actorMetadata?: Maybe; + actorType: Scalars['String']['output']; + description: Scalars['String']['output']; + eventType: Scalars['String']['output']; + id: Scalars['String']['output']; + ipAddress?: Maybe; + newValues?: Maybe; + oldValues?: Maybe; + resourceId: Scalars['String']['output']; + resourceMetadata?: Maybe; + resourceType: Scalars['String']['output']; + timestamp: Scalars['DateTime']['output']; + userAgent: Scalars['String']['output']; +}; + +export type AuditLogsResponseType = { + __typename?: 'AuditLogsResponseType'; + count?: Maybe; + logs?: Maybe>>; +}; + export type AwsCredentialsType = { __typename?: 'AwsCredentialsType'; accessKeyId?: Maybe; @@ -1842,6 +1866,7 @@ export type Query = { appServiceAccounts?: Maybe>>; appUsers?: Maybe>>; apps?: Maybe>>; + auditLogs?: Maybe; awsSecrets?: Maybe>>; awsStsEndpoints?: Maybe>>; clientIp?: Maybe; @@ -1932,6 +1957,19 @@ export type QueryAppsArgs = { }; +export type QueryAuditLogsArgs = { + actorId?: InputMaybe; + end?: InputMaybe; + eventTypes?: InputMaybe>>; + limit?: InputMaybe; + offset?: InputMaybe; + organisationId: Scalars['ID']['input']; + resourceId?: InputMaybe; + resourceType?: InputMaybe; + start?: InputMaybe; +}; + + export type QueryAwsSecretsArgs = { credentialId?: InputMaybe; }; @@ -3608,6 +3646,21 @@ export type CheckOrganisationNameAvailabilityQueryVariables = Exact<{ export type CheckOrganisationNameAvailabilityQuery = { __typename?: 'Query', organisationNameAvailable?: boolean | null }; +export type GetAuditLogsQueryVariables = Exact<{ + organisationId: Scalars['ID']['input']; + start?: InputMaybe; + end?: InputMaybe; + resourceType?: InputMaybe; + resourceId?: InputMaybe; + eventTypes?: InputMaybe> | InputMaybe>; + actorId?: InputMaybe; + offset?: InputMaybe; + limit?: InputMaybe; +}>; + + +export type GetAuditLogsQuery = { __typename?: 'Query', auditLogs?: { __typename?: 'AuditLogsResponseType', count?: number | null, logs?: Array<{ __typename?: 'AuditEventType', id: string, eventType: string, resourceType: string, resourceId: string, actorType: string, actorId: string, actorMetadata?: any | null, resourceMetadata?: any | null, oldValues?: any | null, newValues?: any | null, description: string, ipAddress?: string | null, userAgent: string, timestamp: any } | null> | null } | null }; + export type GetGlobalAccessUsersQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; }>; @@ -4078,6 +4131,7 @@ export const GetAwsStsEndpointsDocument = {"kind":"Document","definitions":[{"ki export const GetIdentityProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetIdentityProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"identityProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"iconId"}},{"kind":"Field","name":{"kind":"Name","value":"supported"}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationIdentitiesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationIdentities"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"identities"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AwsIamConfigType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"trustedPrincipals"}},{"kind":"Field","name":{"kind":"Name","value":"signatureTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"stsEndpoint"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokenNamePattern"}},{"kind":"Field","name":{"kind":"Name","value":"defaultTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"maxTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; export const CheckOrganisationNameAvailabilityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CheckOrganisationNameAvailability"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationNameAvailable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}]}]}}]} as unknown as DocumentNode; +export const GetAuditLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAuditLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"start"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"end"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"resourceType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"resourceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventTypes"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"actorId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"auditLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"start"},"value":{"kind":"Variable","name":{"kind":"Name","value":"start"}}},{"kind":"Argument","name":{"kind":"Name","value":"end"},"value":{"kind":"Variable","name":{"kind":"Name","value":"end"}}},{"kind":"Argument","name":{"kind":"Name","value":"resourceType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"resourceType"}}},{"kind":"Argument","name":{"kind":"Name","value":"resourceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"resourceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"eventTypes"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventTypes"}}},{"kind":"Argument","name":{"kind":"Name","value":"actorId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"actorId"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"resourceType"}},{"kind":"Field","name":{"kind":"Name","value":"resourceId"}},{"kind":"Field","name":{"kind":"Name","value":"actorType"}},{"kind":"Field","name":{"kind":"Name","value":"actorId"}},{"kind":"Field","name":{"kind":"Name","value":"actorMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"resourceMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"oldValues"}},{"kind":"Field","name":{"kind":"Name","value":"newValues"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]} as unknown as DocumentNode; export const GetGlobalAccessUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetGlobalAccessUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationGlobalAccessUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]} as unknown as DocumentNode; export const GetInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationInvites"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetLicenseDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLicenseData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"license"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"customerName"}},{"kind":"Field","name":{"kind":"Name","value":"organisationName"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"seats"}},{"kind":"Field","name":{"kind":"Name","value":"isActivated"}},{"kind":"Field","name":{"kind":"Name","value":"organisationOwner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 62f03b551..1520d3d72 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -23,6 +23,17 @@ type Query { memberType: MemberType environmentId: ID ): SecretLogsResponseType + auditLogs( + organisationId: ID! + start: BigInt + end: BigInt + resourceType: String + resourceId: ID + eventTypes: [String] + actorId: ID + offset: Int + limit: Int + ): AuditLogsResponseType appActivityChart(appId: ID, period: TimeRange): [ChartDataPointType] appEnvironments( appId: ID @@ -843,6 +854,28 @@ type SecretLogsResponseType { count: Int } +type AuditEventType { + id: String! + eventType: String! + resourceType: String! + resourceId: String! + actorType: String! + actorId: String! + actorMetadata: JSONString + resourceMetadata: JSONString + oldValues: JSONString + newValues: JSONString + description: String! + ipAddress: String + userAgent: String! + timestamp: DateTime! +} + +type AuditLogsResponseType { + logs: [AuditEventType] + count: Int +} + enum MemberType { USER SERVICE diff --git a/frontend/app/[team]/logs/page.tsx b/frontend/app/[team]/logs/page.tsx new file mode 100644 index 000000000..5e1e4e661 --- /dev/null +++ b/frontend/app/[team]/logs/page.tsx @@ -0,0 +1,24 @@ +'use client' + +import Spinner from '@/components/common/Spinner' +import AuditLogs from '@/components/logs/AuditLogs' +import { organisationContext } from '@/contexts/organisationContext' +import { useContext } from 'react' + +export default function OrgLogs({ params }: { params: { team: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + + if (!organisation) + return ( +
+ +
+ ) + + return ( +
+

Logs

+ +
+ ) +} diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index f10851c8e..b55357ea7 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -14,6 +14,7 @@ import { FaAngleDoubleLeft, FaAngleDoubleRight, FaChevronDown, + FaClipboardList, } from 'react-icons/fa' import { organisationContext } from '@/contexts/organisationContext' import { SidebarContext } from '@/contexts/sidebarContext' @@ -241,6 +242,12 @@ const Sidebar = () => { icon: , active: usePathname()?.split('/')[2] === `access`, }, + { + name: 'Logs', + href: `/${team}/logs`, + icon: , + active: usePathname()?.split('/')[2] === `logs`, + }, { name: 'Settings', href: `/${team}/settings`, diff --git a/frontend/components/logs/AuditLogs.tsx b/frontend/components/logs/AuditLogs.tsx new file mode 100644 index 000000000..fe64f7246 --- /dev/null +++ b/frontend/components/logs/AuditLogs.tsx @@ -0,0 +1,971 @@ +'use client' + +import { GetAuditLogs } from '@/graphql/queries/organisation/getAuditLogs.gql' +import { GetOrganisationMembers } from '@/graphql/queries/organisation/getOrganisationMembers.gql' +import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { NetworkStatus, useQuery } from '@apollo/client' +import { AuditEventType, OrganisationMemberType, ServiceAccountType } from '@/apollo/graphql' +import { Disclosure, Menu, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { + FaChevronRight, + FaChevronDown, + FaCircle, + FaCheckCircle, + FaBan, + FaUser, + FaRobot, + FaArrowRight, + FaCopy, + FaCheck, + FaCode, +} from 'react-icons/fa' +import { FiRefreshCw, FiChevronsDown } from 'react-icons/fi' +import { FaArrowRotateLeft, FaFilter } from 'react-icons/fa6' +import { relativeTimeFromDates } from '@/utils/time' +import { Fragment, useContext, useState, useEffect } from 'react' +import { Button } from '@/components/common/Button' +import { Count } from 'reaviz' +import { organisationContext } from '@/contexts/organisationContext' +import { userHasPermission } from '@/utils/access/permissions' +import { EmptyState } from '../common/EmptyState' +import { Combobox, RadioGroup } from '@headlessui/react' +import { Avatar } from '../common/Avatar' +import Link from 'next/link' + +const LOGS_START_DATE = 1682904457000 +const DAY = 24 * 60 * 60 * 1000 +const getCurrentTimeStamp = () => Date.now() +const DEFAULT_PAGE_SIZE = 50 +const COUNT_ACCURACY_THRESHOLD = 10000 + +type ResourceTab = { + key: string + label: string + resourceType: string | null +} + +const RESOURCE_TABS: ResourceTab[] = [ + { key: 'all', label: 'All', resourceType: null }, + { key: 'app', label: 'Apps', resourceType: 'app' }, + { key: 'env', label: 'Environments', resourceType: 'env' }, + { key: 'role', label: 'Roles', resourceType: 'role' }, + { key: 'sa', label: 'Service Accounts', resourceType: 'sa' }, + { key: 'member', label: 'Members', resourceType: 'member' }, + { key: 'policy', label: 'Network Policies', resourceType: 'policy' }, + { key: 'tokens', label: 'Tokens', resourceType: null }, +] + +const TOKEN_RESOURCE_TYPES = ['pat', 'sa_token', 'svc_token'] + +const getEventTypeColor = (eventType: string) => { + if (eventType === 'C') return 'bg-emerald-500' + if (eventType === 'U') return 'bg-yellow-500' + if (eventType === 'R') return 'bg-blue-500' + if (eventType === 'D') return 'bg-red-500' + if (eventType === 'A') return 'bg-purple-500' + return 'bg-neutral-500' +} + +const getEventTypeText = (eventType: string) => { + if (eventType === 'C') return 'Create' + if (eventType === 'U') return 'Update' + if (eventType === 'R') return 'Read' + if (eventType === 'D') return 'Delete' + if (eventType === 'A') return 'Access' + return eventType +} + +const getResourceTypeLabel = (resourceType: string) => { + const labels: Record = { + app: 'App', + env: 'Environment', + role: 'Role', + sa: 'Service Account', + member: 'Member', + policy: 'Network Policy', + pat: 'Personal Access Token', + sa_token: 'SA Token', + svc_token: 'Service Token', + } + return labels[resourceType] || resourceType +} + +const parseJsonField = (val: any): Record | null => { + if (!val) return null + if (typeof val === 'object') return val + try { + return JSON.parse(val) + } catch { + return null + } +} + +/** Build a link to the resource if possible */ +const getResourceLink = ( + log: AuditEventType, + resourceMeta: Record | null, + team: string +): string | null => { + // Don't link to deleted resources + if (log.eventType === 'D') return null + + const rt = log.resourceType + const id = log.resourceId + + if (rt === 'app') return `/${team}/apps/${id}` + if (rt === 'env' && resourceMeta?.app_id) return `/${team}/apps/${resourceMeta.app_id}/environments/${id}` + if (rt === 'role') return `/${team}/access/roles` + if (rt === 'sa') return `/${team}/access/service-accounts/${id}` + if (rt === 'member') return `/${team}/access/members` + if (rt === 'policy') return `/${team}/access/network` + + return null +} + +const JsonDetailButton = ({ log }: { log: AuditEventType }) => { + const [copied, setCopied] = useState(false) + const [showTooltip, setShowTooltip] = useState(false) + + const jsonData = JSON.stringify( + { + id: log.id, + event_type: log.eventType, + resource_type: log.resourceType, + resource_id: log.resourceId, + actor_type: log.actorType, + actor_id: log.actorId, + actor_metadata: parseJsonField(log.actorMetadata), + resource_metadata: parseJsonField(log.resourceMetadata), + old_values: parseJsonField(log.oldValues), + new_values: parseJsonField(log.newValues), + description: log.description, + ip_address: log.ipAddress, + user_agent: log.userAgent, + timestamp: log.timestamp, + }, + null, + 2 + ) + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + await navigator.clipboard.writeText(jsonData) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + + + {showTooltip && ( +
+
{jsonData}
+
+ )} +
+ ) +} + +const LogRow = ({ + log, + members, + serviceAccounts, + team, +}: { + log: AuditEventType + members: OrganisationMemberType[] + serviceAccounts: ServiceAccountType[] + team: string +}) => { + const actorMeta = parseJsonField(log.actorMetadata) + const resourceMeta = parseJsonField(log.resourceMetadata) + const oldValues = parseJsonField(log.oldValues) + const newValues = parseJsonField(log.newValues) + + // Resolve actor display + const member = members.find( + (m) => + m.id === log.actorId || + m.email === actorMeta?.email || + (actorMeta?.username && m.fullName === actorMeta?.username) + ) + const sa = log.actorType === 'sa' + ? serviceAccounts.find((s) => s.id === log.actorId) + : null + + const actorDisplayName = member + ? member.fullName || member.email || 'User' + : sa + ? sa.name + : log.actorType === 'sa' + ? actorMeta?.name || 'Service Account' + : actorMeta?.email || actorMeta?.username || 'User' + + const ActorAvatar = ({ size = 'sm' }: { size?: 'sm' | 'md' }) => + member ? ( + + ) : sa ? ( + + ) : log.actorType === 'sa' ? ( + + ) : ( + + ) + + const relativeTimeStamp = relativeTimeFromDates(new Date(log.timestamp)) + const verboseTimeStamp = new Date(log.timestamp).toISOString() + + const resourceLink = getResourceLink(log, resourceMeta, team) + + const LogField = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ {label}: + {children} +
+ ) + + const ChangeDiff = ({ + oldVals, + newVals, + }: { + oldVals: Record | null + newVals: Record | null + }) => { + // Collect only keys where values actually differ + const allKeys = new Set([ + ...Object.keys(oldVals || {}), + ...Object.keys(newVals || {}), + ]) + + const changedKeys = Array.from(allKeys).filter((key) => { + const oldVal = oldVals?.[key] + const newVal = newVals?.[key] + return JSON.stringify(oldVal) !== JSON.stringify(newVal) + }) + + if (changedKeys.length === 0) return null + + return ( +
+ {changedKeys.map((key) => { + const oldVal = oldVals?.[key] + const newVal = newVals?.[key] + const isObj = typeof oldVal === 'object' || typeof newVal === 'object' + + if (isObj) { + return ( +
+ {key} + {oldVal !== undefined && ( +
+                    - {JSON.stringify(oldVal, null, 2)}
+                  
+ )} + {newVal !== undefined && ( +
+                    + {JSON.stringify(newVal, null, 2)}
+                  
+ )} +
+ ) + } + + return ( +
+ {key}: + {oldVal !== undefined && ( + + {String(oldVal)} + + )} + {oldVal !== undefined && newVal !== undefined && ( + + )} + {newVal !== undefined && ( + + {String(newVal)} + + )} +
+ ) + })} +
+ ) + } + + const hasChanges = + (oldValues || newValues) && + Array.from( + new Set([...Object.keys(oldValues || {}), ...Object.keys(newValues || {})]) + ).some((key) => JSON.stringify(oldValues?.[key]) !== JSON.stringify(newValues?.[key])) + + return ( + + {({ open }) => ( + <> + + + + + +
+ + {actorDisplayName} +
+ + +
+ +
+ {getEventTypeText(log.eventType)} +
+
+ + + + {getResourceTypeLabel(log.resourceType)} + + + {log.description} + + {relativeTimeStamp} + +
+ + + + {/* Header row with JSON button */} +
+
+ +
+ + {actorDisplayName} +
+
+ + +
+ {getResourceTypeLabel(log.resourceType)} + {resourceMeta?.name && ` (${resourceMeta.name})`} + {resourceLink && ( + e.stopPropagation()}> + + + )} +
+
+ + + {log.resourceId} + + + {resourceMeta?.app_name && ( + +
+ {resourceMeta.app_name} + {resourceMeta?.app_id && ( + e.stopPropagation()} + > + + + )} +
+
+ )} + + {log.ipAddress || 'N/A'} + + + + {log.userAgent || 'N/A'} + + + + + {log.id} + + + {verboseTimeStamp} +
+ +
+ +
+
+ + {hasChanges && ( +
+

+ Changes +

+ +
+ )} +
+ +
+ + )} +
+ ) +} + +const SkeletonRow = ({ rows }: { rows: number }) => { + const SKELETON_BASE = 'bg-neutral-300 dark:bg-neutral-700 animate-pulse' + + return ( + <> + {[...Array(rows)].map((_, n) => ( + + + + + +
+
+
+
+ + +
+ +
+
+ + +
+ + +
+ + +
+ + + ))} + + ) +} + +export default function AuditLogs() { + const { activeOrganisation: organisation } = useContext(organisationContext) + const team = organisation?.name || '' + + const [activeTab, setActiveTab] = useState('all') + const [queryStart, setQueryStart] = useState(LOGS_START_DATE) + const [queryEnd, setQueryEnd] = useState(getCurrentTimeStamp()) + const [eventTypes, setEventTypes] = useState([]) + const [selectedMember, setSelectedMember] = useState(null) + const [dateRange, setDateRange] = useState<'7' | '30' | '90' | 'custom' | null>(null) + const [memberQuery, setMemberQuery] = useState('') + + useEffect(() => { + const now = getCurrentTimeStamp() + if (dateRange === '7') { + setQueryStart(now - 7 * DAY) + setQueryEnd(now) + } else if (dateRange === '30') { + setQueryStart(now - 30 * DAY) + setQueryEnd(now) + } else if (dateRange === '90') { + setQueryStart(now - 90 * DAY) + setQueryEnd(now) + } else if (dateRange === null) { + setQueryStart(LOGS_START_DATE) + setQueryEnd(getCurrentTimeStamp()) + } + }, [dateRange]) + + const activeTabDef = RESOURCE_TABS.find((t) => t.key === activeTab)! + const isTokensTab = activeTab === 'tokens' + + const { data, loading, fetchMore, refetch, networkStatus } = useQuery(GetAuditLogs, { + variables: { + organisationId: organisation?.id, + start: queryStart, + end: queryEnd, + resourceType: isTokensTab ? null : activeTabDef.resourceType, + eventTypes: eventTypes.length ? eventTypes : null, + actorId: selectedMember ? selectedMember.id : null, + offset: 0, + limit: DEFAULT_PAGE_SIZE, + }, + fetchPolicy: 'network-only', + notifyOnNetworkStatusChange: true, + skip: !organisation, + }) + + const { data: membersData } = useQuery(GetOrganisationMembers, { + variables: { organisationId: organisation?.id }, + skip: !organisation, + }) + + const { data: saData } = useQuery(GetServiceAccounts, { + variables: { orgId: organisation?.id }, + skip: !organisation, + }) + + const isRefetching = networkStatus === NetworkStatus.refetch || loading + const isFetchingMore = networkStatus === NetworkStatus.fetchMore + + const allLogs: AuditEventType[] = (data?.auditLogs?.logs || []).filter( + Boolean + ) as AuditEventType[] + + // Client-side filter for tokens tab (multiple resource types) + const logs = isTokensTab + ? allLogs.filter((l) => TOKEN_RESOURCE_TYPES.includes(l.resourceType)) + : allLogs + + const totalCount = data?.auditLogs?.count || 0 + const endOfList = allLogs.length >= totalCount + + const members: OrganisationMemberType[] = (membersData?.organisationMembers || []).filter( + Boolean + ) as OrganisationMemberType[] + + const serviceAccounts: ServiceAccountType[] = (saData?.serviceAccounts || []).filter( + Boolean + ) as ServiceAccountType[] + + const userCanReadLogs = organisation + ? userHasPermission(organisation?.role?.permissions, 'Logs', 'read', false) + : false + + const handleRefetch = async () => { + const now = Date.now() + await refetch({ + organisationId: organisation?.id, + start: queryStart, + end: dateRange === 'custom' ? queryEnd : now, + resourceType: isTokensTab ? null : activeTabDef.resourceType, + eventTypes: eventTypes.length ? eventTypes : null, + actorId: selectedMember?.id ?? null, + offset: 0, + limit: DEFAULT_PAGE_SIZE, + }) + } + + const loadMore = () => { + if (loading || isFetchingMore) return + fetchMore({ + variables: { offset: allLogs.length }, + updateQuery: (prev: any, { fetchMoreResult }: any) => { + if (!fetchMoreResult?.auditLogs?.logs?.length) return prev + return { + ...prev, + auditLogs: { + ...prev.auditLogs, + logs: [...prev.auditLogs.logs, ...fetchMoreResult.auditLogs.logs], + count: prev.auditLogs.count, + }, + } + }, + }) + } + + const clearFilters = () => { + setEventTypes([]) + setSelectedMember(null) + setDateRange(null) + setQueryStart(LOGS_START_DATE) + setQueryEnd(getCurrentTimeStamp()) + } + + const hasActiveFilters = + eventTypes.length > 0 || selectedMember !== null || dateRange !== null + + const filterCategoryTitleStyle = + 'text-[11px] font-semibold text-neutral-500 tracking-widest uppercase' + + function formatTimestampForInput(ts: number): string { + const date = new Date(ts) + const pad = (n: number) => n.toString().padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` + } + + return ( + <> + {userCanReadLogs ? ( +
+ {/* Resource type tabs */} +
+ {RESOURCE_TABS.map((tab) => ( + + ))} +
+ + {/* Toolbar */} +
+ + {totalCount >= COUNT_ACCURACY_THRESHOLD ? '~' : ''} + {totalCount !== undefined && } Events + + +
+ {/* Filter menu */} + + {({ open }) => ( + <> +
+ +
+ + {hasActiveFilters && ( + + )} +
+
+
+ + + + {/* Event types */} +
+
Event Type
+
+ {( + [ + { code: 'C', label: 'Create', color: 'emerald' }, + { code: 'U', label: 'Update', color: 'yellow' }, + { code: 'R', label: 'Read', color: 'blue' }, + { code: 'D', label: 'Delete', color: 'red' }, + { code: 'A', label: 'Access', color: 'purple' }, + ] as const + ).map((ev) => ( + + ))} +
+
+ + {/* Member filter */} +
+
Account
+ setSelectedMember(val)} + > + {({ open }) => ( + <> +
+ setMemberQuery(e.target.value)} + displayValue={(val: OrganisationMemberType | null) => + val ? val.fullName || val.email || '' : '' + } + placeholder="Search members" + /> + + + +
+ {open && ( + +
+ {members + .filter((m) => { + if (!memberQuery) return true + const q = memberQuery.toLowerCase() + return ( + (m.fullName || '').toLowerCase().includes(q) || + (m.email || '').toLowerCase().includes(q) + ) + }) + .map((m) => ( + + {({ active, selected }) => ( +
+
+ + {m.fullName || m.email} +
+ {selected && ( + + )} +
+ )} +
+ ))} +
+
+ )} + + )} +
+
+ + {/* Date range */} +
+
Date range
+ + {[ + { value: '7', label: 'Last 7d' }, + { value: '30', label: 'Last 30d' }, + { value: '90', label: 'Last 90d' }, + { value: 'custom', label: 'Custom' }, + ].map(({ value, label }) => ( + + {({ checked }) => ( + + )} + + ))} + + {dateRange === 'custom' && ( +
+
+ + + setQueryStart(new Date(e.target.value).getTime()) + } + /> +
+
+ + + setQueryEnd(new Date(e.target.value).getTime()) + } + /> +
+
+ )} +
+ +
+ +
+
+
+ + )} +
+ + {/* Refresh */} + +
+
+ + {/* Table */} + + + + + + + + + + + + + {logs.map((log, n) => ( + + {n !== 0 && n % DEFAULT_PAGE_SIZE === 0 && ( + + + + )} + + + ))} + + {loading && } + + + + + +
ActorEventResourceDescriptionTime
+
+ Page {n / DEFAULT_PAGE_SIZE + 1} +
+
+
+ {!endOfList && ( + + )} + {endOfList && `No${logs.length ? ' more ' : ' '}logs to show`} +
+
+
+ ) : ( + + +
+ } + > + <> + + )} + + ) +} diff --git a/frontend/graphql/queries/organisation/getAuditLogs.gql b/frontend/graphql/queries/organisation/getAuditLogs.gql new file mode 100644 index 000000000..79c8d50bd --- /dev/null +++ b/frontend/graphql/queries/organisation/getAuditLogs.gql @@ -0,0 +1,41 @@ +query GetAuditLogs( + $organisationId: ID! + $start: BigInt + $end: BigInt + $resourceType: String + $resourceId: ID + $eventTypes: [String] + $actorId: ID + $offset: Int + $limit: Int +) { + auditLogs( + organisationId: $organisationId + start: $start + end: $end + resourceType: $resourceType + resourceId: $resourceId + eventTypes: $eventTypes + actorId: $actorId + offset: $offset + limit: $limit + ) { + logs { + id + eventType + resourceType + resourceId + actorType + actorId + actorMetadata + resourceMetadata + oldValues + newValues + description + ipAddress + userAgent + timestamp + } + count + } +} From 592782112dee9922689d98104a642dfc025098b3 Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 16 Mar 2026 20:20:58 +0530 Subject: [PATCH 08/20] fix: migration graph Signed-off-by: rohan --- .../api/migrations/{0117_auditevent.py => 0118_auditevent.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/api/migrations/{0117_auditevent.py => 0118_auditevent.py} (97%) diff --git a/backend/api/migrations/0117_auditevent.py b/backend/api/migrations/0118_auditevent.py similarity index 97% rename from backend/api/migrations/0117_auditevent.py rename to backend/api/migrations/0118_auditevent.py index fb7b52015..0e5515590 100644 --- a/backend/api/migrations/0117_auditevent.py +++ b/backend/api/migrations/0118_auditevent.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0116_app_description'), + ('api', '0117_alter_environmentsync_service_and_more'), ] operations = [ From 0184d81ce1286ca42df48887225d0373196d6d2a Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 16 Mar 2026 20:21:12 +0530 Subject: [PATCH 09/20] chore: regenerate graphql types Signed-off-by: rohan --- frontend/apollo/gql.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index c2da72174..bbab0f48d 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -123,6 +123,7 @@ type Documents = { "query GetIdentityProviders {\n identityProviders {\n id\n name\n description\n iconId\n supported\n }\n}": typeof types.GetIdentityProvidersDocument, "query GetOrganisationIdentities($organisationId: ID!) {\n identities(organisationId: $organisationId) {\n id\n provider\n name\n description\n config {\n ... on AwsIamConfigType {\n trustedPrincipals\n signatureTtlSeconds\n stsEndpoint\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n createdAt\n }\n}": typeof types.GetOrganisationIdentitiesDocument, "query CheckOrganisationNameAvailability($name: String!) {\n organisationNameAvailable(name: $name)\n}": typeof types.CheckOrganisationNameAvailabilityDocument, + "query GetAuditLogs($organisationId: ID!, $start: BigInt, $end: BigInt, $resourceType: String, $resourceId: ID, $eventTypes: [String], $actorId: ID, $offset: Int, $limit: Int) {\n auditLogs(\n organisationId: $organisationId\n start: $start\n end: $end\n resourceType: $resourceType\n resourceId: $resourceId\n eventTypes: $eventTypes\n actorId: $actorId\n offset: $offset\n limit: $limit\n ) {\n logs {\n id\n eventType\n resourceType\n resourceId\n actorType\n actorId\n actorMetadata\n resourceMetadata\n oldValues\n newValues\n description\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}": typeof types.GetAuditLogsDocument, "query GetGlobalAccessUsers($organisationId: ID!) {\n organisationGlobalAccessUsers(organisationId: $organisationId) {\n id\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": typeof types.GetGlobalAccessUsersDocument, "query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n inviteeEmail\n role {\n id\n name\n description\n color\n }\n }\n}": typeof types.GetInvitesDocument, "query GetLicenseData {\n license {\n id\n customerName\n organisationName\n expiresAt\n plan\n seats\n isActivated\n organisationOwner {\n fullName\n email\n }\n }\n}": typeof types.GetLicenseDataDocument, From b26d78c59f167781eaf6d00b8a8882ada3335ded Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 16 Mar 2026 21:03:45 +0530 Subject: [PATCH 10/20] fix: enhance captured log event data Signed-off-by: rohan --- backend/backend/graphene/mutations/app.py | 72 +++++++++++++++++-- .../backend/graphene/mutations/environment.py | 62 +++++++++++++++- .../graphene/mutations/service_accounts.py | 5 +- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/backend/backend/graphene/mutations/app.py b/backend/backend/graphene/mutations/app.py index de9e74fdb..0e7c6a68a 100644 --- a/backend/backend/graphene/mutations/app.py +++ b/backend/backend/graphene/mutations/app.py @@ -9,6 +9,7 @@ from graphql import GraphQLError from api.models import ( App, + Environment, EnvironmentKey, Organisation, OrganisationMember, @@ -336,7 +337,29 @@ def mutate(cls, root, info, app_id, members): actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) ip_address, user_agent = get_resolver_request_meta(info.context) - member_ids = [m.member_id for m in members] + + # Build per-member details with names and env scopes + members_detail = [] + for member_input in members: + mid = member_input.member_id + mtype = member_input.member_type + env_ids = [k.env_id for k in member_input.env_keys] + env_names = sorted( + Environment.objects.filter(id__in=env_ids).values_list("name", flat=True) + ) + if mtype == MemberType.USER: + m = OrganisationMember.objects.filter(id=mid, deleted_at=None).first() + mname = (m.user.username or m.user.email or str(mid)) if m else str(mid) + else: + m = ServiceAccount.objects.filter(id=mid, deleted_at=None).first() + mname = m.name if m else str(mid) + members_detail.append({ + "id": str(mid), + "name": mname, + "type": mtype.value if hasattr(mtype, 'value') else str(mtype), + "env_scope": env_names, + }) + log_audit_event( organisation=app.organisation, event_type="A", @@ -346,7 +369,7 @@ def mutate(cls, root, info, app_id, members): actor_id=actor_id, actor_metadata=actor_metadata, resource_metadata={"name": app.name}, - new_values={"members_added": member_ids}, + new_values={"members_added": members_detail}, description=f"Added {len(members)} member(s) to app '{app.name}'", ip_address=ip_address, user_agent=user_agent, @@ -409,6 +432,17 @@ def mutate( EnvironmentKey.objects.update_or_create(**condition, defaults=defaults) + # Resolve member name and initial env scope for audit log + if member_type == MemberType.USER: + member_name = member.user.username or member.user.email or str(member_id) + else: + member_name = member.name + + env_ids = [key.env_id for key in env_keys] + env_names = sorted( + Environment.objects.filter(id__in=env_ids).values_list("name", flat=True) + ) + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) ip_address, user_agent = get_resolver_request_meta(info.context) log_audit_event( @@ -420,8 +454,13 @@ def mutate( actor_id=actor_id, actor_metadata=actor_metadata, resource_metadata={"name": app.name}, - new_values={"member_id": str(member_id), "member_type": member_type.value if hasattr(member_type, 'value') else str(member_type)}, - description=f"Added member to app '{app.name}'", + new_values={ + "member_id": str(member_id), + "member_name": member_name, + "member_type": member_type.value if hasattr(member_type, 'value') else str(member_type), + "env_scope": env_names, + }, + description=f"Added {member_name} to app '{app.name}'", ip_address=ip_address, user_agent=user_agent, ) @@ -466,6 +505,20 @@ def mutate(cls, root, info, member_id, app_id, member_type=MemberType.USER): if not member: raise GraphQLError("Invalid member type or ID") + # Capture member name and env scope before removal + if member_type == MemberType.USER: + member_name = member.user.username or member.user.email or str(member_id) + env_scope_qs = EnvironmentKey.objects.filter(environment__app=app, user_id=member_id) + else: + member_name = member.name + env_scope_qs = EnvironmentKey.objects.filter(environment__app=app, service_account_id=member_id) + + env_names = sorted( + Environment.objects.filter( + id__in=env_scope_qs.values_list("environment_id", flat=True) + ).values_list("name", flat=True) + ) + if member_type == MemberType.USER: if member not in app.members.all(): raise GraphQLError("This user is not a member of this app") @@ -495,8 +548,15 @@ def mutate(cls, root, info, member_id, app_id, member_type=MemberType.USER): actor_id=actor_id, actor_metadata=actor_metadata, resource_metadata={"name": app.name}, - old_values={"member_id": str(member_id), "member_type": member_type.value if hasattr(member_type, 'value') else str(member_type)}, - description=f"Removed member from app '{app.name}'", + old_values={ + "members_removed": [{ + "id": str(member_id), + "name": member_name, + "type": member_type.value if hasattr(member_type, 'value') else str(member_type), + "env_scope": env_names, + }], + }, + description=f"Removed {member_name} from app '{app.name}'", ip_address=ip_address, user_agent=user_agent, ) diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index 123d9d21f..7ee165e95 100644 --- a/backend/backend/graphene/mutations/environment.py +++ b/backend/backend/graphene/mutations/environment.py @@ -455,6 +455,14 @@ def mutate( "This service account does not have access to this app" ) + # Capture old env scope for audit logging + old_env_key_filter = dict(key_to_delete_filter) + old_env_ids = set( + EnvironmentKey.objects.filter(**old_env_key_filter) + .values_list("environment_id", flat=True) + ) + new_env_ids = set(key.env_id for key in env_keys) + with transaction.atomic(): # delete all existing keys for this member EnvironmentKey.objects.filter(**key_to_delete_filter).delete() @@ -473,6 +481,54 @@ def mutate( identity_key=key.identity_key, ) + # Audit log the env scope change + if old_env_ids != new_env_ids: + actor_type, actor_id, actor_metadata = get_actor_info_from_graphql(info) + ip_address, user_agent = get_resolver_request_meta(info.context) + + # Compute the actual diff — only envs that were added or removed + added_env_ids = new_env_ids - old_env_ids + removed_env_ids = old_env_ids - new_env_ids + + # Resolve env names for readability + env_name_map = dict( + Environment.objects.filter( + id__in=added_env_ids | removed_env_ids + ).values_list("id", "name") + ) + added_env_names = sorted(env_name_map.get(eid, str(eid)) for eid in added_env_ids) + removed_env_names = sorted(env_name_map.get(eid, str(eid)) for eid in removed_env_ids) + + if member_type == MemberType.USER: + member_name = app_member.user.username or app_member.user.email or str(member_id) + resource_type = "member" + else: + member_name = app_member.name + resource_type = "sa" + + old_values = {} + new_values = {} + if removed_env_names: + old_values["envs_removed"] = removed_env_names + if added_env_names: + new_values["envs_added"] = added_env_names + + log_audit_event( + organisation=app.organisation, + event_type="A", + resource_type=resource_type, + resource_id=str(member_id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"name": member_name, "app_name": app.name, "app_id": str(app.id)}, + old_values=old_values, + new_values=new_values, + description=f"Updated environment scope for {member_name} in app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return UpdateMemberEnvScopeMutation(app=app) @@ -683,7 +739,7 @@ def mutate( actor_type=actor_type, actor_id=actor_id, actor_metadata=actor_metadata, - resource_metadata={"name": name, "app": app.name}, + resource_metadata={"name": name, "app_name": app.name, "app_id": str(app.id)}, description=f"Created service token '{name}' for app '{app.name}'", ip_address=ip_address, user_agent=user_agent, @@ -709,6 +765,8 @@ def mutate(cls, root, info, token_id): token_name = token.name token_id = token.id + app_name = token.app.name + app_id = str(token.app.id) token.deleted_at = timezone.now() token.save() @@ -723,7 +781,7 @@ def mutate(cls, root, info, token_id): actor_type=actor_type, actor_id=actor_id, actor_metadata=actor_metadata, - resource_metadata={"name": token_name}, + resource_metadata={"name": token_name, "app_name": app_name, "app_id": app_id}, description=f"Deleted service token '{token_name}'", ip_address=ip_address, user_agent=user_agent, diff --git a/backend/backend/graphene/mutations/service_accounts.py b/backend/backend/graphene/mutations/service_accounts.py index f437632fa..26f4a25fd 100644 --- a/backend/backend/graphene/mutations/service_accounts.py +++ b/backend/backend/graphene/mutations/service_accounts.py @@ -363,7 +363,7 @@ def mutate( actor_type=actor_type, actor_id=actor_id, actor_metadata=actor_metadata, - resource_metadata={"name": name, "service_account": service_account.name}, + resource_metadata={"name": name, "service_account": service_account.name, "service_account_id": str(service_account.id)}, description=f"Created service account token '{name}' for '{service_account.name}'", ip_address=ip_address, user_agent=user_agent, @@ -394,6 +394,7 @@ def mutate(cls, root, info, token_id): token_id = token.id token_org = token.service_account.organisation sa_name = token.service_account.name + sa_id = str(token.service_account.id) token.delete() @@ -407,7 +408,7 @@ def mutate(cls, root, info, token_id): actor_type=actor_type, actor_id=actor_id, actor_metadata=actor_metadata, - resource_metadata={"name": token_name, "service_account": sa_name}, + resource_metadata={"name": token_name, "service_account": sa_name, "service_account_id": sa_id}, description=f"Deleted service account token '{token_name}' from '{sa_name}'", ip_address=ip_address, user_agent=user_agent, From 2c07ca13dd4787cdbd7cc99ecc5fdcd0834e4c01 Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 16 Mar 2026 21:04:01 +0530 Subject: [PATCH 11/20] feat: rescale and improve audit logs ui Signed-off-by: rohan --- frontend/app/[team]/logs/page.tsx | 4 +- frontend/components/logs/AuditLogs.tsx | 373 +++++++++++++++---------- 2 files changed, 226 insertions(+), 151 deletions(-) diff --git a/frontend/app/[team]/logs/page.tsx b/frontend/app/[team]/logs/page.tsx index 5e1e4e661..acc7972fb 100644 --- a/frontend/app/[team]/logs/page.tsx +++ b/frontend/app/[team]/logs/page.tsx @@ -16,8 +16,8 @@ export default function OrgLogs({ params }: { params: { team: string } }) { ) return ( -
-

Logs

+
+

Logs

) diff --git a/frontend/components/logs/AuditLogs.tsx b/frontend/components/logs/AuditLogs.tsx index fe64f7246..4064ceb20 100644 --- a/frontend/components/logs/AuditLogs.tsx +++ b/frontend/components/logs/AuditLogs.tsx @@ -16,9 +16,6 @@ import { FaUser, FaRobot, FaArrowRight, - FaCopy, - FaCheck, - FaCode, } from 'react-icons/fa' import { FiRefreshCw, FiChevronsDown } from 'react-icons/fi' import { FaArrowRotateLeft, FaFilter } from 'react-icons/fa6' @@ -119,61 +116,14 @@ const getResourceLink = ( if (rt === 'sa') return `/${team}/access/service-accounts/${id}` if (rt === 'member') return `/${team}/access/members` if (rt === 'policy') return `/${team}/access/network` + if (rt === 'pat') return `/${team}/access/authentication` + if (rt === 'sa_token' && resourceMeta?.service_account_id) + return `/${team}/access/service-accounts/${resourceMeta.service_account_id}` + if (rt === 'svc_token' && resourceMeta?.app_id) return `/${team}/apps/${resourceMeta.app_id}` return null } -const JsonDetailButton = ({ log }: { log: AuditEventType }) => { - const [copied, setCopied] = useState(false) - const [showTooltip, setShowTooltip] = useState(false) - - const jsonData = JSON.stringify( - { - id: log.id, - event_type: log.eventType, - resource_type: log.resourceType, - resource_id: log.resourceId, - actor_type: log.actorType, - actor_id: log.actorId, - actor_metadata: parseJsonField(log.actorMetadata), - resource_metadata: parseJsonField(log.resourceMetadata), - old_values: parseJsonField(log.oldValues), - new_values: parseJsonField(log.newValues), - description: log.description, - ip_address: log.ipAddress, - user_agent: log.userAgent, - timestamp: log.timestamp, - }, - null, - 2 - ) - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation() - await navigator.clipboard.writeText(jsonData) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - return ( -
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - > - - - {showTooltip && ( -
-
{jsonData}
-
- )} -
- ) -} const LogRow = ({ log, @@ -227,9 +177,108 @@ const LogRow = ({ const resourceLink = getResourceLink(log, resourceMeta, team) const LogField = ({ label, children }: { label: string; children: React.ReactNode }) => ( -
+
{label}: - {children} + {children} +
+ ) + + /** Resolve a UUID to a member or service account display chip */ + const resolveAccountId = (id: string) => { + const m = members.find((m) => m.id === id) + if (m) + return ( + + + {m.fullName || m.email} + + ) + const s = serviceAccounts.find((s) => s.id === id) + if (s) + return ( + + + {s.name} + + ) + return {id} + } + + /** Check if an array contains string account IDs */ + const isAccountIdList = (key: string, val: any): boolean => { + if (!Array.isArray(val) || val.length === 0) return false + // Must be an array of strings (not objects) + if (!val.every((v: any) => typeof v === 'string')) return false + const accountKeys = ['members_added', 'members_removed', 'apps_granted', 'apps_revoked'] + if (accountKeys.includes(key)) return true + // Heuristic: array of UUID-like strings + return val.every((v: any) => v.length >= 32 && v.includes('-')) + } + + /** Check if an array contains member detail objects (from bulk add) */ + const isMemberDetailList = (val: any): boolean => { + if (!Array.isArray(val) || val.length === 0) return false + return val.every((v: any) => typeof v === 'object' && v !== null && 'id' in v && 'name' in v) + } + + const AccountList = ({ + ids, + variant, + }: { + ids: string[] + variant: 'added' | 'removed' + }) => ( +
+ {ids.map((id) => ( + + {variant === 'removed' ? '- ' : '+ '} + {resolveAccountId(id)} + + ))} +
+ ) + + /** Render member detail objects (from bulk add with env scope) */ + const MemberDetailList = ({ + items, + variant, + }: { + items: Array<{ id: string; name: string; type?: string; env_scope?: string[] }> + variant: 'added' | 'removed' + }) => ( +
+ {items.map((item) => ( +
+ + {variant === 'removed' ? '- ' : '+ '} + {resolveAccountId(item.id)} + + {item.env_scope && item.env_scope.length > 0 && ( + + ({item.env_scope.join(', ')}) + + )} +
+ ))}
) @@ -259,6 +308,37 @@ const LogRow = ({ {changedKeys.map((key) => { const oldVal = oldVals?.[key] const newVal = newVals?.[key] + + // Render member detail objects (from bulk add/remove with env scope) + if (isMemberDetailList(oldVal) || isMemberDetailList(newVal)) { + return ( +
+ {key} + {oldVal !== undefined && Array.isArray(oldVal) && ( + + )} + {newVal !== undefined && Array.isArray(newVal) && ( + + )} +
+ ) + } + + // Render account ID lists with avatars + if (isAccountIdList(key, oldVal) || isAccountIdList(key, newVal)) { + return ( +
+ {key} + {oldVal !== undefined && Array.isArray(oldVal) && ( + + )} + {newVal !== undefined && Array.isArray(newVal) && ( + + )} +
+ ) + } + const isObj = typeof oldVal === 'object' || typeof newVal === 'object' if (isObj) { @@ -323,40 +403,40 @@ const LogRow = ({ > - -
+ +
{actorDisplayName}
- +
-
+
{getEventTypeText(log.eventType)}
- - + + {getResourceTypeLabel(log.resourceType)} - {log.description} - + {log.description} + {relativeTimeStamp} @@ -372,49 +452,27 @@ const LogRow = ({ - {/* Header row with JSON button */} -
-
- -
- - {actorDisplayName} -
-
- - -
- {getResourceTypeLabel(log.resourceType)} - {resourceMeta?.name && ` (${resourceMeta.name})`} - {resourceLink && ( - e.stopPropagation()}> - - - )} -
-
- - - {log.resourceId} - +
+
+ +
+ + {actorDisplayName} +
+
- {resourceMeta?.app_name && ( - +
- {resourceMeta.app_name} - {resourceMeta?.app_id && ( - e.stopPropagation()} - > + {getResourceTypeLabel(log.resourceType)} + {resourceMeta?.name && ` (${resourceMeta.name})`} + {resourceLink && ( + e.stopPropagation()}> @@ -422,39 +480,56 @@ const LogRow = ({ )}
- )} - {log.ipAddress || 'N/A'} + + {log.resourceId} + - - - {log.userAgent || 'N/A'} - - + {resourceMeta?.app_name && ( + +
+ {resourceMeta.app_name} + {resourceMeta?.app_id && ( + e.stopPropagation()} + > + + + )} +
+
+ )} + + {log.ipAddress || 'N/A'} + + + + {log.userAgent || 'N/A'} + + - - {log.id} - + + {log.id} + - {verboseTimeStamp} -
+ {verboseTimeStamp} +
-
- -
+ {hasChanges && ( +
+

+ Changes +

+ +
+ )}
- - {hasChanges && ( -
-

- Changes -

- -
- )} @@ -474,29 +549,29 @@ const SkeletonRow = ({ rows }: { rows: number }) => { key={n} className="py-4 border-b border-neutral-500/20 transition duration-300 ease-in-out" > - - + + - -
-
-
+ +
+
+
- +
- -
+ +
- -
+ +
- -
+ +
- -
+ +
))} @@ -646,16 +721,16 @@ export default function AuditLogs() { {userCanReadLogs ? (
{/* Resource type tabs */} -
+
{RESOURCE_TABS.map((tab) => ( + + )} +
+ + + + {log.resourceId} + - + {resourceMeta?.app_name && ( +
- {getResourceTypeLabel(log.resourceType)} - {resourceMeta?.name && ` (${resourceMeta.name})`} - {resourceLink && ( - e.stopPropagation()}> + {resourceMeta.app_name} + {resourceMeta?.app_id && ( + e.stopPropagation()} + > @@ -483,55 +608,34 @@ const LogRow = ({ )}
+ )} - - {log.resourceId} - + {log.ipAddress || 'N/A'} - {resourceMeta?.app_name && ( - -
- {resourceMeta.app_name} - {resourceMeta?.app_id && ( - e.stopPropagation()} - > - - - )} -
-
- )} - - {log.ipAddress || 'N/A'} - - - - {log.userAgent || 'N/A'} - - + + + {log.userAgent || 'N/A'} + + - - {log.id} - + + {log.id} + - {verboseTimeStamp} -
+ {verboseTimeStamp} +
- {hasChanges && ( -
-

- Changes -

- -
- )} + {hasChanges && ( +
+

+ Changes +

+ +
+ )}
@@ -707,8 +811,7 @@ export default function AuditLogs() { setQueryEnd(getCurrentTimeStamp()) } - const hasActiveFilters = - eventTypes.length > 0 || selectedMember !== null || dateRange !== null + const hasActiveFilters = eventTypes.length > 0 || selectedMember !== null || dateRange !== null const filterCategoryTitleStyle = 'text-[11px] font-semibold text-neutral-500 tracking-widest uppercase' @@ -816,11 +919,7 @@ export default function AuditLogs() { }[ev.color] )} > - {eventTypes.includes(ev.code) ? ( - - ) : ( - - )} + {eventTypes.includes(ev.code) ? : } {' '} {ev.label} @@ -942,9 +1041,7 @@ export default function AuditLogs() { type="datetime-local" className="flex-1 p-1 rounded-md bg-neutral-200 dark:bg-neutral-800 text-xs" value={formatTimestampForInput(queryEnd)} - onChange={(e) => - setQueryEnd(new Date(e.target.value).getTime()) - } + onChange={(e) => setQueryEnd(new Date(e.target.value).getTime())} />
@@ -982,11 +1079,11 @@ export default function AuditLogs() { - Actor - Event - Resource + Actor + Event + Resource Description - Time + Time