diff --git a/backend/api/auth.py b/backend/api/auth.py index 190d9308f..b04ba25bf 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,93 @@ def authenticate(self, request): # Try resolving env from query params else: - try: - app_id = request.GET.get("app_id") - env_name = request.GET.get("env") - if not app_id: - raise exceptions.AuthenticationFailed( - "Missing app_id parameter" - ) - if not env_name: - raise exceptions.AuthenticationFailed("Missing env parameter") - # Pre-fetch app and organisation - env = Environment.objects.select_related("app__organisation").get( - app_id=app_id, name__iexact=env_name - ) - except Environment.DoesNotExist: - # Check if the app exists to give a more specific error + app_id = request.GET.get("app_id") + env_name = request.GET.get("env") + + if app_id and env_name: + # Resolve environment from app_id + env name + try: + env = Environment.objects.select_related( + "app__organisation" + ).get(app_id=app_id, name__iexact=env_name) + except Environment.DoesNotExist: + App = apps.get_model("api", "App") + if not App.objects.filter(id=app_id).exists(): + raise exceptions.NotFound( + f"App with ID {app_id} not found" + ) + else: + raise exceptions.NotFound( + f"Environment '{env_name}' not found in App {app_id}" + ) + + elif app_id and not env_name: + # App-only mode: resolve app directly (no environment needed) App = apps.get_model("api", "App") - if not App.objects.filter(id=app_id).exists(): - raise exceptions.NotFound(f"App with ID {app_id} not found") - else: + try: + app = App.objects.select_related("organisation").get( + id=app_id + ) + except App.DoesNotExist: raise exceptions.NotFound( - f"Environment '{env_name}' not found in App {app_id}" + f"App with ID {app_id} not found" + ) + auth["app"] = app + + else: + # No app_id in query params — check URL kwargs for env_id + # (used by detail endpoints like /environments//) + env_id_from_url = None + if ( + hasattr(request, "resolver_match") + and request.resolver_match + ): + env_id_from_url = ( + request.resolver_match.kwargs.get("env_id") ) + if env_id_from_url: + try: + env_lookup = Environment.objects.select_related( + "app__organisation" + ).get(id=env_id_from_url) + auth["app"] = env_lookup.app + except Environment.DoesNotExist: + raise exceptions.NotFound("Environment not found") + else: + # Check URL kwargs for app_id + # (used by detail endpoints like /apps//) + app_id_from_url = None + if ( + hasattr(request, "resolver_match") + and request.resolver_match + ): + app_id_from_url = ( + request.resolver_match.kwargs.get("app_id") + ) + + if app_id_from_url: + App = apps.get_model("api", "App") + try: + app = App.objects.select_related( + "organisation" + ).get(id=app_id_from_url) + except App.DoesNotExist: + raise exceptions.NotFound( + f"App with ID {app_id_from_url} not found" + ) + auth["app"] = app + else: + # Org-only mode: no app, no env + # Organisation resolved from token below + auth["org_only"] = True + auth["environment"] = env + # When env is resolved, also populate auth["app"] for convenience + if env is not None: + auth["app"] = env.app + if token_type == "User": try: org_member = get_org_member_from_user_token(auth_token) @@ -135,10 +198,26 @@ def authenticate(self, request): auth["org_member"] = org_member user = org_member.user - if not user_can_access_environment(user.userId, env.id): - raise exceptions.PermissionDenied("User cannot access this environment") + if auth.get("org_only"): + # Org-only mode: resolve organisation from the member + auth["organisation"] = org_member.organisation + elif env: + if not user_can_access_environment(user.userId, env.id): + raise exceptions.PermissionDenied( + "User cannot access this environment" + ) + else: + # App-only mode + if not user_can_access_app(user.userId, auth["app"].id): + raise exceptions.PermissionDenied( + "User cannot access this app" + ) elif token_type == "Service": + if env is None: + raise exceptions.AuthenticationFailed( + "Service tokens require an environment context" + ) service_token = get_service_token(auth_token) auth["service_token"] = service_token user = service_token.created_by.user @@ -158,22 +237,43 @@ def authenticate(self, request): auth["service_account"] = service_account auth["service_account_token"] = service_token - if not service_account_can_access_environment( - service_account.id, env.id - ): - raise exceptions.AuthenticationFailed( - "Service account cannot access this environment" - ) + if auth.get("org_only"): + # Org-only mode: resolve organisation from the SA + auth["organisation"] = service_account.organisation + elif env: + if not service_account_can_access_environment( + service_account.id, env.id + ): + raise exceptions.AuthenticationFailed( + "Service account cannot access this environment" + ) + else: + # App-only mode: check SA is a member of this app + if not auth["app"].service_accounts.filter( + id=service_account.id, deleted_at=None + ).exists(): + raise exceptions.AuthenticationFailed( + "Service account cannot access this app" + ) + except exceptions.AuthenticationFailed: + raise + except exceptions.NotFound: + raise except Exception as ex: # Distinguish between ServiceAccount not found and other potential errors ServiceAccount = apps.get_model("api", "ServiceAccount") try: # Attempt to get the service account again to confirm if it exists get_service_account_from_token(auth_token) - # If it exists, the error was likely the environment access check - raise exceptions.AuthenticationFailed( - "Service account cannot access this environment" - ) + # If it exists, the error was likely the access check + if env: + raise exceptions.AuthenticationFailed( + "Service account cannot access this environment" + ) + else: + raise exceptions.AuthenticationFailed( + "Service account cannot access this app" + ) except ServiceAccount.DoesNotExist: raise exceptions.NotFound("Service account not found") except ( @@ -181,7 +281,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/emails.py b/backend/api/emails.py index 67794e643..641038b90 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -83,10 +83,18 @@ def send_login_email(request, email, full_name, provider): ) +def _get_invite_sender_name(invite): + if invite.invited_by: + return get_org_member_name(invite.invited_by) + if invite.invited_by_service_account: + return invite.invited_by_service_account.name + return invite.organisation.name + + def send_invite_email(invite): organisation = invite.organisation.name - invited_by_name = get_org_member_name(invite.invited_by) + invited_by_name = _get_invite_sender_name(invite) invite_code = encode_string_to_base64(str(invite.id)) @@ -116,7 +124,7 @@ def send_user_joined_email(invite, new_member): owner_name = get_org_member_name(owner) - invited_by_name = get_org_member_name(invite.invited_by) + invited_by_name = _get_invite_sender_name(invite) if owner_name == invited_by_name: invited_by_name = "you" diff --git a/backend/api/migrations/0118_auditevent.py b/backend/api/migrations/0118_auditevent.py new file mode 100644 index 000000000..0e5515590 --- /dev/null +++ b/backend/api/migrations/0118_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', '0117_alter_environmentsync_service_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AuditEvent', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(choices=[('C', 'Create'), ('R', 'Read'), ('U', 'Update'), ('D', 'Delete'), ('A', 'Access')], max_length=1)), + ('resource_type', models.CharField(choices=[('app', 'App'), ('env', 'Environment'), ('role', 'Role'), ('sa', 'ServiceAccount'), ('member', 'OrganisationMember'), ('policy', 'NetworkAccessPolicy'), ('pat', 'UserToken'), ('sa_token', 'ServiceAccountToken'), ('svc_token', 'ServiceToken')], max_length=10)), + ('resource_id', models.TextField()), + ('actor_type', models.CharField(choices=[('user', 'User'), ('sa', 'ServiceAccount')], max_length=10)), + ('actor_id', models.TextField()), + ('actor_metadata', models.JSONField(default=dict)), + ('resource_metadata', models.JSONField(default=dict)), + ('old_values', models.JSONField(blank=True, null=True)), + ('new_values', models.JSONField(blank=True, null=True)), + ('description', models.TextField(blank=True, default='')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True, default='')), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_events', to='api.organisation')), + ], + options={ + 'indexes': [models.Index(fields=['resource_type', 'resource_id', '-timestamp'], name='audit_resource_history_idx'), models.Index(fields=['organisation', '-timestamp'], name='audit_org_activity_idx'), models.Index(fields=['actor_type', 'actor_id', '-timestamp'], name='audit_actor_activity_idx')], + }, + ), + ] diff --git a/backend/api/migrations/0119_auditevent_invite_resource_type.py b/backend/api/migrations/0119_auditevent_invite_resource_type.py new file mode 100644 index 000000000..c4c63a735 --- /dev/null +++ b/backend/api/migrations/0119_auditevent_invite_resource_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-17 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0118_auditevent'), + ] + + operations = [ + migrations.AlterField( + model_name='auditevent', + name='resource_type', + field=models.CharField(choices=[('app', 'App'), ('env', 'Environment'), ('role', 'Role'), ('sa', 'ServiceAccount'), ('member', 'OrganisationMember'), ('policy', 'NetworkAccessPolicy'), ('pat', 'UserToken'), ('sa_token', 'ServiceAccountToken'), ('svc_token', 'ServiceToken'), ('invite', 'Invite')], max_length=10), + ), + ] diff --git a/backend/api/migrations/0120_invite_sa_inviter.py b/backend/api/migrations/0120_invite_sa_inviter.py new file mode 100644 index 000000000..f93b45e10 --- /dev/null +++ b/backend/api/migrations/0120_invite_sa_inviter.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.29 on 2026-03-18 08:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0119_auditevent_invite_resource_type'), + ] + + operations = [ + migrations.AlterField( + model_name='organisationmemberinvite', + name='invited_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.organisationmember'), + ), + migrations.AddField( + model_name='organisationmemberinvite', + name='invited_by_service_account', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.serviceaccount'), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 9f99a19e3..d076e58e7 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -340,7 +340,12 @@ class OrganisationMemberInvite(models.Model): null=True, blank=True, ) - invited_by = models.ForeignKey(OrganisationMember, on_delete=models.CASCADE) + invited_by = models.ForeignKey( + OrganisationMember, on_delete=models.SET_NULL, null=True, blank=True + ) + invited_by_service_account = models.ForeignKey( + "ServiceAccount", on_delete=models.SET_NULL, null=True, blank=True + ) invitee_email = models.EmailField() valid = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) @@ -952,6 +957,96 @@ 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" + INVITE = "invite" + 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"), + (INVITE, "Invite"), + ] + + 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/serializers.py b/backend/api/serializers.py index ad4df6b96..e6c199f15 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -8,12 +8,14 @@ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from .models import ( + App, CustomUser, Environment, EnvironmentKey, Lockbox, Organisation, OrganisationMember, + OrganisationMemberInvite, Secret, ServiceAccount, ServiceToken, @@ -74,6 +76,8 @@ class Meta: "full_name", "email", "role", + "created_at", + "updated_at", ] read_only_fields = fields @@ -90,6 +94,37 @@ def get_role(self, obj): return {"id": r.id, "name": r.name} +class OrganisationMemberInviteSerializer(serializers.ModelSerializer): + + role = serializers.SerializerMethodField() + invited_by = serializers.SerializerMethodField() + + class Meta: + model = OrganisationMemberInvite + fields = [ + "id", + "invitee_email", + "role", + "invited_by", + "created_at", + "expires_at", + "valid", + ] + read_only_fields = fields + + def get_role(self, obj): + if not obj.role: + return None + return {"id": str(obj.role.id), "name": obj.role.name} + + def get_invited_by(self, obj): + if obj.invited_by: + return {"type": "member", "email": obj.invited_by.user.email} + if obj.invited_by_service_account: + return {"type": "service_account", "name": obj.invited_by_service_account.name} + return None + + class ServiceAccountSerializer(serializers.ModelSerializer): role = serializers.SerializerMethodField() @@ -217,6 +252,12 @@ def get_type(self, obj): return obj.type +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 b5289fee3..0ae7ec13f 100644 --- a/backend/api/throttling.py +++ b/backend/api/throttling.py @@ -43,6 +43,24 @@ 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 + 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/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 11e20c611..75a38c9cf 100644 --- a/backend/api/utils/audit_logging.py +++ b/backend/api/utils/audit_logging.py @@ -1,6 +1,26 @@ +import logging + from django.utils import timezone -from api.models import SecretEvent +from api.models import AuditEvent, SecretEvent + +logger = logging.getLogger(__name__) + + +def get_member_display_name(org_member): + """ + Return the best human-readable display name for an OrganisationMember. + Prefers the social account full name, falls back to email. + """ + try: + social_acc = org_member.user.socialaccount_set.first() + if social_acc: + name = social_acc.extra_data.get("name") + if name: + return name + except Exception: + pass + return getattr(org_member.user, "email", str(org_member.id)) def log_secret_event( @@ -42,3 +62,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/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/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/apps.py b/backend/api/views/apps.py new file mode 100644 index 000000000..9ad65ee13 --- /dev/null +++ b/backend/api/views/apps.py @@ -0,0 +1,423 @@ +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, user_is_org_member +from api.utils.crypto import ( + encrypt_raw, + env_keypair, + get_server_keypair, + random_hex, + 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, 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 + +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 + org = self._get_org(request) + if not user_is_org_member(account.userId, org.id): + raise PermissionDenied("You are not a member of this organisation.") + 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, + ) + + # 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) + + +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 + org_member = request.auth["org_member"] + if not app.members.filter(id=org_member.id).exists(): + raise PermissionDenied("You do not have access to this app.") + elif request.auth["auth_type"] == "ServiceAccount": + account = request.auth["service_account"] + is_sa = True + 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, "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, + ) + + 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( + {"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() + + # 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 + + 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() + + # 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 new file mode 100644 index 000000000..49a454be6 --- /dev/null +++ b/backend/api/views/environments.py @@ -0,0 +1,357 @@ +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.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, 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 + +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 + org_member = request.auth["org_member"] + if not app.members.filter(id=org_member.id).exists(): + raise PermissionDenied("You do not have access to this app.") + 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, + ) + + # 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) + + +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 + org_member = request.auth["org_member"] + if not app.members.filter(id=org_member.id).exists(): + raise PermissionDenied("You do not have access to this app.") + 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, + ) + + 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) + + 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_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/members.py b/backend/api/views/members.py new file mode 100644 index 000000000..34026b542 --- /dev/null +++ b/backend/api/views/members.py @@ -0,0 +1,764 @@ +import logging +from datetime import timedelta + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.utils import timezone + +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 api.auth import PhaseTokenAuthentication +from api.models import ( + App, + Environment, + EnvironmentKey, + OrganisationMember, + OrganisationMemberInvite, + Role, + ServerEnvironmentKey, +) +from api.serializers import OrganisationMemberSerializer, OrganisationMemberInviteSerializer +from api.utils.access.permissions import ( + role_has_global_access, + role_has_permission, + user_has_permission, +) +from api.utils.audit_logging import log_audit_event, get_actor_info, get_member_display_name +from api.utils.crypto import decrypt_asymmetric, get_server_keypair +from api.utils.environments import _ed25519_pk_to_curve25519, _wrap_env_secrets_for_key +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_account + +logger = logging.getLogger(__name__) + +CLOUD_HOSTED = settings.APP_HOST == "cloud" +INVITE_EXPIRY_DAYS = 14 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + + +def _get_org(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 _check_permission(request, action): + """Check RBAC for the given action on the Members resource.""" + 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 = _get_org(request) + if not user_has_permission(account, action, "Members", org, False, is_sa): + raise PermissionDenied(f"You don't have permission to {action} members.") + + +# --------------------------------------------------------------------------- +# Members: list / invite +# --------------------------------------------------------------------------- + + +class PublicMembersView(APIView): + """ + GET /public/v1/members/ — list active org members + POST /public/v1/members/ — invite a new member (creates an invite, not a direct member) + """ + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + 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}") + _check_permission(request, action) + + def get(self, request, *args, **kwargs): + org = _get_org(request) + members = ( + OrganisationMember.objects.select_related("user", "role") + .filter(organisation=org, deleted_at=None) + .order_by("created_at") + ) + return Response(OrganisationMemberSerializer(members, many=True).data, status=status.HTTP_200_OK) + + def post(self, request, *args, **kwargs): + org = _get_org(request) + invited_by = request.auth.get("org_member") # None for SA tokens + + email = request.data.get("email", "").strip().lower() + role_id = request.data.get("role_id") + + if not email: + return Response( + {"error": "Missing required field: email"}, + status=status.HTTP_400_BAD_REQUEST, + ) + 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 (Role.DoesNotExist, ValueError): + return Response({"error": "Role not found."}, status=status.HTTP_404_NOT_FOUND) + + # Restrict invitable roles: no global-access (Owner/Admin) or SA-token-create roles + if role_has_global_access(role): + return Response( + {"error": f"Members cannot be invited with the '{role.name}' role."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if role_has_permission(role, "create", "ServiceAccountTokens"): + return Response( + { + "error": "Members cannot be invited with a role that allows creating service account tokens." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Conflict checks + if OrganisationMember.objects.filter( + organisation=org, user__email=email, deleted_at=None + ).exists(): + return Response( + {"error": f"'{email}' is already a member of this organisation."}, + status=status.HTTP_409_CONFLICT, + ) + if OrganisationMemberInvite.objects.filter( + organisation=org, invitee_email=email, valid=True, expires_at__gte=timezone.now() + ).exists(): + return Response( + {"error": f"An active invite already exists for '{email}'."}, + status=status.HTTP_409_CONFLICT, + ) + + # Quota check + if not can_add_account(org, 1): + return Response( + {"error": "Member quota exceeded for this organisation's plan."}, + status=status.HTTP_403_FORBIDDEN, + ) + + app_ids = request.data.get("apps", []) + if not isinstance(app_ids, list): + return Response( + {"error": "'apps' must be a list of app IDs."}, + status=status.HTTP_400_BAD_REQUEST, + ) + app_scope = App.objects.filter(id__in=app_ids, organisation=org, is_deleted=False) + + invited_by_sa = ( + request.auth["service_account"] + if request.auth["auth_type"] == "ServiceAccount" + else None + ) + + expiry = timezone.now() + timedelta(days=INVITE_EXPIRY_DAYS) + invite = OrganisationMemberInvite.objects.create( + organisation=org, + role=role, + invited_by=invited_by, + invited_by_service_account=invited_by_sa, + invitee_email=email, + expires_at=expiry, + ) + invite.apps.set(app_scope) + + try: + from api.tasks.emails import send_invite_email_job + + send_invite_email_job(invite) + except Exception as e: + logger.warning("Failed to send invite email to %s: %s", email, e) + + 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="invite", + resource_id=str(invite.id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"email": email, "role": role.name}, + description=f"Invited '{email}' with role '{role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + + return Response(OrganisationMemberInviteSerializer(invite).data, status=status.HTTP_201_CREATED) + + +# --------------------------------------------------------------------------- +# Member detail: get / update role / delete +# --------------------------------------------------------------------------- + + +class PublicMemberDetailView(APIView): + """ + GET /public/v1/members// — member detail + PUT /public/v1/members// — update member role + DELETE /public/v1/members// — remove member (soft-delete) + """ + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + 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}") + _check_permission(request, action) + + def _get_member(self, member_id, org): + try: + return OrganisationMember.objects.select_related("user", "role").get( + id=member_id, organisation=org, deleted_at=None + ) + except (ObjectDoesNotExist, ValueError): + return None + + def get(self, request, member_id, *args, **kwargs): + org = _get_org(request) + member = self._get_member(member_id, org) + if not member: + return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND) + return Response(OrganisationMemberSerializer(member).data, status=status.HTTP_200_OK) + + def put(self, request, member_id, *args, **kwargs): + org = _get_org(request) + member = self._get_member(member_id, org) + if not member: + return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND) + + # Prevent self role-update + if request.auth["auth_type"] == "User": + acting_member = request.auth["org_member"] + if str(acting_member.id) == str(member.id): + return Response( + {"error": "You cannot update your own role."}, + status=status.HTTP_403_FORBIDDEN, + ) + # Prevent updating a global-access member unless the caller also has global access + if role_has_global_access(member.role) and not role_has_global_access( + acting_member.role + ): + return Response( + {"error": "You cannot update the role of a member with global access."}, + status=status.HTTP_403_FORBIDDEN, + ) + + 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: + new_role = Role.objects.get(id=role_id, organisation=org) + except (Role.DoesNotExist, ValueError): + return Response({"error": "Role not found."}, status=status.HTTP_404_NOT_FOUND) + + # Owner role can only be transferred via the dedicated ownership transfer flow + if new_role.name.lower() == "owner": + return Response( + {"error": "You cannot assign the Owner role directly. Use the ownership transfer flow."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Only global-access callers can assign global-access roles; SAs never can + if role_has_global_access(new_role): + can_assign = ( + request.auth["auth_type"] == "User" + and role_has_global_access(request.auth["org_member"].role) + ) + if not can_assign: + return Response( + {"error": f"You cannot assign the '{new_role.name}' role."}, + status=status.HTTP_403_FORBIDDEN, + ) + + old_role_name = member.role.name + member.role = new_role + member.save() + + 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="member", + resource_id=str(member.id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"email": member.user.email}, + old_values={"role": old_role_name}, + new_values={"role": new_role.name}, + description=f"Updated member '{get_member_display_name(member)}' role from '{old_role_name}' to '{new_role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + + return Response(OrganisationMemberSerializer(member).data, status=status.HTTP_200_OK) + + def delete(self, request, member_id, *args, **kwargs): + org = _get_org(request) + member = self._get_member(member_id, org) + if not member: + return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND) + + # Prevent self-removal + if request.auth["auth_type"] == "User": + if str(request.auth["org_member"].id) == str(member.id): + return Response( + {"error": "You cannot remove yourself from the organisation."}, + status=status.HTTP_403_FORBIDDEN, + ) + + member_display_name = get_member_display_name(member) + member_email = member.user.email + member_role = member.role.name + + member.deleted_at = timezone.now() + member.save() + + if CLOUD_HOSTED: + from ee.billing.stripe import update_stripe_subscription_seats + + update_stripe_subscription_seats(org) + + 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="member", + resource_id=str(member_id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"email": member_email}, + old_values={"email": member_email, "role": member_role}, + description=f"Removed member '{member_display_name}' from the organisation", + ip_address=ip_address, + user_agent=user_agent, + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +# --------------------------------------------------------------------------- +# Member access management (server-side key wrapping, SSE apps only) +# --------------------------------------------------------------------------- + + +class PublicMemberAccessView(APIView): + """ + PUT /public/v1/members//access/ + + Declaratively set a member's app/environment access. The member's ed25519 + identity_key is used to wrap environment secrets server-side (SSE apps only). + + Input: + { + "apps": [ + {"id": "", "environments": ["", ...]}, + ... + ] + } + + Apps not in the list have their access revoked. Environments within each + app are synced to exactly the provided list. + """ + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + _check_permission(request, "update") + + def put(self, request, member_id, *args, **kwargs): + org = _get_org(request) + + try: + member = OrganisationMember.objects.select_related("user", "role").get( + id=member_id, organisation=org, deleted_at=None + ) + except (ObjectDoesNotExist, ValueError): + return Response({"error": "Member not found."}, status=status.HTTP_404_NOT_FOUND) + + if not member.identity_key: + return Response( + { + "error": ( + "Member has not set up their identity key yet. " + "They must log in to the console first." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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, + ) + + # --- Validate all apps and environments upfront --- + desired_app_ids = set() + desired_env_map = {} # app_id (str) -> set of env_id (str) + + 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": "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 entirely." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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 + + # --- Derive member's x25519 key from their stored ed25519 identity_key --- + try: + member_kx_pub = _ed25519_pk_to_curve25519(member.identity_key) + except Exception as e: + logger.error("Failed to derive kx pubkey for member %s: %s", member_id, e) + return Response( + {"error": "Failed to process member's identity key."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + server_pk, server_sk = get_server_keypair() + + # --- Apply all changes atomically --- + apps_to_add = set() + apps_to_remove = set() + + with transaction.atomic(): + current_app_ids = set(str(a.id) for a in member.apps.filter(is_deleted=False)) + + # Revoke access to apps no longer in the desired list + apps_to_remove = current_app_ids - desired_app_ids + if apps_to_remove: + member.apps.remove(*App.objects.filter(id__in=apps_to_remove)) + EnvironmentKey.objects.filter( + user=member, + environment__app__id__in=apps_to_remove, + deleted_at=None, + ).update(deleted_at=timezone.now()) + + # Grant access to new apps + apps_to_add = desired_app_ids - current_app_ids + if apps_to_add: + member.apps.add(*App.objects.filter(id__in=apps_to_add)) + + # 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) + + current_env_keys = EnvironmentKey.objects.filter( + user=member, + environment__app_id=app_id_str, + deleted_at=None, + ) + current_env_ids = set(str(ek.environment_id) for ek in current_env_keys) + + # Revoke environments no longer desired + envs_to_revoke = current_env_ids - desired_env_id_strs + if envs_to_revoke: + EnvironmentKey.objects.filter( + user=member, + environment_id__in=envs_to_revoke, + deleted_at=None, + ).update(deleted_at=timezone.now()) + + # Grant new environments via server-side key wrapping + envs_to_grant = desired_env_id_strs - current_env_ids + if envs_to_grant: + new_env_keys = [] + for env_id in envs_to_grant: + 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 with server keys, re-wrap for member + 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() + ) + wrapped_seed, wrapped_salt = _wrap_env_secrets_for_key( + env_seed, env_salt, member_kx_pub + ) + + env = Environment.objects.get(id=env_id) + new_env_keys.append( + EnvironmentKey( + environment=env, + user=member, + identity_key=sek.identity_key, + wrapped_seed=wrapped_seed, + wrapped_salt=wrapped_salt, + ) + ) + + if new_env_keys: + EnvironmentKey.objects.bulk_create(new_env_keys) + + # Build detailed access change data for audit log + app_name_map = { + str(a.id): a.name + for a in App.objects.filter(id__in=desired_app_ids | apps_to_remove) + } + env_name_map = { + str(e.id): e.name + for e in Environment.objects.filter(app_id__in=desired_app_ids | apps_to_remove) + } + + access_detail = [] + for app_id_str in apps_to_add: + env_ids = desired_env_map.get(app_id_str, []) + access_detail.append({ + "id": app_id_str, + "name": app_name_map.get(app_id_str, app_id_str), + "env_scope": sorted(env_name_map.get(str(e), str(e)) for e in env_ids), + }) + + revoked_detail = [] + for app_id_str in apps_to_remove: + revoked_detail.append({ + "id": app_id_str, + "name": app_name_map.get(app_id_str, app_id_str), + }) + + new_values = {} + old_values = {} + if access_detail: + new_values["apps_granted"] = access_detail + if revoked_detail: + old_values["apps_revoked"] = revoked_detail + + # Include env scope changes for apps that weren't added/removed + existing_apps = desired_app_ids - apps_to_add + for app_id_str in existing_apps: + desired_envs = set(str(e) for e in desired_env_map.get(app_id_str, [])) + current_envs = set( + str(ek.environment_id) + for ek in EnvironmentKey.objects.filter( + user=member, + environment__app_id=app_id_str, + deleted_at=None, + ) + ) + added_envs = desired_envs - current_envs + removed_envs = current_envs - desired_envs + if added_envs or removed_envs: + app_name = app_name_map.get(app_id_str, app_id_str) + if added_envs: + new_values.setdefault("envs_added", []).append({ + "app": app_name, + "environments": sorted(env_name_map.get(e, e) for e in added_envs), + }) + if removed_envs: + old_values.setdefault("envs_removed", []).append({ + "app": app_name, + "environments": sorted(env_name_map.get(e, e) for e in removed_envs), + }) + + member_display = get_member_display_name(member) + 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="member", + resource_id=str(member.id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"email": member.user.email}, + old_values=old_values or None, + new_values=new_values or None, + description=f"Updated access for member '{member_display}'", + ip_address=ip_address, + user_agent=user_agent, + ) + + return Response(OrganisationMemberSerializer(member).data, status=status.HTTP_200_OK) + + +# --------------------------------------------------------------------------- +# Invites: list / cancel +# --------------------------------------------------------------------------- + + +class PublicInvitesView(APIView): + """ + GET /public/v1/invites/ — list pending (valid, non-expired) invites + """ + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + _check_permission(request, "read") + + def get(self, request, *args, **kwargs): + org = _get_org(request) + invites = ( + OrganisationMemberInvite.objects.select_related("role", "invited_by__user") + .filter(organisation=org, valid=True, expires_at__gte=timezone.now()) + .order_by("-created_at") + ) + return Response(OrganisationMemberInviteSerializer(invites, many=True).data, status=status.HTTP_200_OK) + + +class PublicInviteDetailView(APIView): + """ + DELETE /public/v1/invites// — cancel a pending invite + """ + + authentication_classes = [PhaseTokenAuthentication] + permission_classes = [IsAuthenticated, IsIPAllowed] + throttle_classes = [PlanBasedRateThrottle] + renderer_classes = [CamelCaseJSONRenderer] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + _check_permission(request, "delete") + + def delete(self, request, invite_id, *args, **kwargs): + org = _get_org(request) + + try: + invite = OrganisationMemberInvite.objects.select_related("role").get( + id=invite_id, organisation=org + ) + except (ObjectDoesNotExist, ValueError): + return Response({"error": "Invite not found."}, status=status.HTTP_404_NOT_FOUND) + + invite_email = invite.invitee_email + invite_role = invite.role.name + + invite.delete() + + 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="invite", + resource_id=str(invite_id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_meta, + resource_metadata={"email": invite_email, "role": invite_role}, + description=f"Cancelled invite for '{invite_email}'", + 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 new file mode 100644 index 000000000..593765ad3 --- /dev/null +++ b/backend/api/views/roles.py @@ -0,0 +1,466 @@ +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.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 + +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__) + +# 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.""" + 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, + ) + + 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: + 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, + ) + + # 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, + ) + + +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") + + 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."}, + 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, + ) + perm_error = _validate_permissions(permissions) + if perm_error: + return Response( + {"error": perm_error}, + status=status.HTTP_400_BAD_REQUEST, + ) + role.permissions = permissions + + 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, + ) + + 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_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 new file mode 100644 index 000000000..3eaf0e2b9 --- /dev/null +++ b/backend/api/views/service_accounts.py @@ -0,0 +1,786 @@ +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.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 + +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}" + + # 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 + 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, + ) + + 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") + + 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() + + # 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): + 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_name = sa.name + org = sa.organisation + sa.delete() + + if CLOUD_HOSTED: + from ee.billing.stripe import update_stripe_subscription_seats + + 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) + + +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 env_ids is None or not isinstance(env_ids, list): + return Response( + {"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, + ) + + # 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) + + # Audit log — build detailed access change data + app_name_map = { + str(a.id): a.name + for a in App.objects.filter( + id__in=desired_app_ids | apps_to_remove + ) + } + env_name_map = { + str(e.id): e.name + for e in Environment.objects.filter( + app_id__in=desired_app_ids | apps_to_remove + ) + } + + access_detail = [] + for app_id_str in apps_to_add: + env_ids = desired_env_map.get(app_id_str, []) + access_detail.append({ + "id": app_id_str, + "name": app_name_map.get(app_id_str, app_id_str), + "env_scope": sorted(env_name_map.get(str(e), str(e)) for e in env_ids), + }) + + revoked_detail = [] + for app_id_str in apps_to_remove: + revoked_detail.append({ + "id": app_id_str, + "name": app_name_map.get(app_id_str, app_id_str), + }) + + new_values = {} + old_values = {} + if access_detail: + new_values["apps_granted"] = access_detail + if revoked_detail: + old_values["apps_revoked"] = revoked_detail + + # Include env scope changes for apps that weren't added/removed + existing_apps = desired_app_ids - apps_to_add + for app_id_str in existing_apps: + desired_envs = set(str(e) for e in desired_env_map.get(app_id_str, [])) + current_envs = set( + str(ek.environment_id) + for ek in EnvironmentKey.objects.filter( + service_account=sa, + environment__app_id=app_id_str, + deleted_at=None, + ) + ) + added_envs = desired_envs - current_envs # already applied above in atomic block + removed_envs = current_envs - desired_envs + if added_envs or removed_envs: + app_name = app_name_map.get(app_id_str, app_id_str) + if added_envs: + new_values.setdefault("envs_added", []).append({ + "app": app_name, + "environments": sorted(env_name_map.get(e, e) for e in added_envs), + }) + if removed_envs: + old_values.setdefault("envs_removed", []).append({ + "app": app_name, + "environments": sorted(env_name_map.get(e, e) for e in removed_envs), + }) + + if new_values or 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="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}, + old_values=old_values or None, + new_values=new_values or None, + 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..1e214bd6a 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, @@ -16,6 +17,8 @@ ServiceAccount, ) from backend.graphene.types import AppType, MemberType +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql, get_member_display_name +from api.utils.rest import get_resolver_request_meta from django.conf import settings from django.db.models import Q @@ -85,6 +88,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 +163,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 +185,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 +246,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 +335,50 @@ 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) + + # 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 = get_member_display_name(m) 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", + 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": members_detail}, + description=( + f"Added {members_detail[0]['name']} to app '{app.name}'" + if len(members_detail) == 1 + else f"Added {len(members_detail)} members to app '{app.name}'" + ), + ip_address=ip_address, + user_agent=user_agent, + ) + return BulkAddAppMembersMutation(app=app) @@ -323,6 +436,41 @@ 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 = get_member_display_name(member) + 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( + 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": [{ + "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"Added {member_name} to app '{app.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return AddAppMemberMutation(app=app) @@ -363,6 +511,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 = get_member_display_name(member) + 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") @@ -381,4 +543,28 @@ 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={ + "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, + ) + return RemoveAppMemberMutation(app=app) diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index 1a2077415..91516aa69 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, get_member_display_name 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 @@ -191,6 +191,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) @@ -230,10 +246,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) @@ -259,8 +294,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) @@ -402,6 +456,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() @@ -420,6 +482,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 = get_member_display_name(app_member) + 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) @@ -490,6 +600,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: @@ -509,9 +635,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") @@ -585,6 +730,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_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, + ) + return CreateServiceTokenMutation(service_token=service_token) @@ -603,9 +764,30 @@ 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 + app_name = token.app.name + app_id = str(token.app.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, "app_name": app_name, "app_id": app_id}, + 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..02fb4b528 100644 --- a/backend/backend/graphene/mutations/organisation.py +++ b/backend/backend/graphene/mutations/organisation.py @@ -6,6 +6,8 @@ user_is_org_member, ) from api.utils.access.roles import default_roles +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql, get_member_display_name +from api.utils.rest import get_resolver_request_meta from api.tasks.emails import send_invite_email_job from backend.quotas import can_add_account import graphene @@ -24,6 +26,8 @@ OrganisationMemberType, OrganisationType, ) +from api.utils.audit_logging import log_audit_event, get_actor_info_from_graphql, get_member_display_name +from api.utils.rest import get_resolver_request_meta from datetime import timedelta from django.utils import timezone from django.conf import settings @@ -200,6 +204,22 @@ def mutate(cls, root, info, org_id, invites): created_invites.append(new_invite) + 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="invite", + resource_id=str(new_invite.id), + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"email": email, "role": role.name}, + description=f"Invited '{email}' with role '{role.name}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return BulkInviteOrganisationMembersMutation(invites=created_invites) @@ -219,8 +239,26 @@ def mutate(cls, rooot, info, invite_id): raise GraphQLError("You dont have permission to delete invites") if user_is_org_member(info.context.user, invite.organisation.id): + invite_email = invite.invitee_email + invite_id = str(invite.id) invite.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=invite.organisation, + event_type="D", + resource_type="invite", + resource_id=invite_id, + actor_type=actor_type, + actor_id=actor_id, + actor_metadata=actor_metadata, + resource_metadata={"email": invite_email}, + description=f"Deleted invite for '{invite_email}'", + ip_address=ip_address, + user_agent=user_agent, + ) + return DeleteInviteMutation(ok=True) else: @@ -314,12 +352,33 @@ 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", "") + member_display_name = get_member_display_name(org_member) + 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_display_name}'", + ip_address=ip_address, + user_agent=user_agent, + ) return DeleteOrganisationMemberMutation(ok=True) @@ -362,9 +421,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 '{get_member_display_name(org_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..26f4a25fd 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, "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, + ) + return CreateServiceAccountTokenMutation(token=token) @@ -336,6 +390,28 @@ 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 + sa_id = str(token.service_account.id) + 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, "service_account_id": sa_id}, + 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 85199a916..de78dae4f 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, @@ -273,6 +274,7 @@ class Meta: fields = ( "id", "invited_by", + "invited_by_service_account", "invitee_email", "valid", "organisation", @@ -976,6 +978,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 280476c4b..83f148cec 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.syncing.azure.key_vault import AzureKeyVaultSecretType from api.utils.database import get_approximate_count from ee.integrations.secrets.dynamic.graphene.mutations import ( @@ -161,6 +162,7 @@ UpdateSyncAuthentication, ) from api.utils.access.permissions import ( + role_has_global_access, user_can_access_app, user_can_access_environment, user_has_permission, @@ -189,6 +191,8 @@ from .graphene.types import ( ActivatedPhaseLicenseType, AppType, + AuditEventType, + AuditLogsResponseType, ChartDataPointType, EnvironmentKeyType, EnvironmentSyncType, @@ -247,7 +251,7 @@ import time import logging import heapq -from django.db.models import prefetch_related_objects +from django.db.models import Q, prefetch_related_objects logger = logging.getLogger(__name__) @@ -313,6 +317,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(), @@ -842,6 +859,85 @@ 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") + + # Scope to resources the user can actually access + if not role_has_global_access(org_member.role): + accessible_app_ids = set( + org_member.apps.values_list("id", flat=True) + ) + accessible_env_ids = set( + EnvironmentKey.objects.filter( + user=org_member, deleted_at=None + ).values_list("environment_id", flat=True) + ) + org_scoped_types = [ + "role", "member", "invite", "policy", "pat", "sa", + ] + qs = qs.filter( + Q(resource_type__in=org_scoped_types) + | Q(resource_type="app", resource_id__in=accessible_app_ids) + | Q(resource_type="env", resource_id__in=accessible_env_ids) + | Q(resource_type__in=["sa_token", "svc_token"]) + ) + + 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 ad0d5cf0e..a47deeb94 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -4,7 +4,22 @@ 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.service_accounts import ( + PublicServiceAccountsView, + PublicServiceAccountDetailView, + PublicServiceAccountAccessView, +) +from api.views.roles import PublicRolesView, PublicRoleDetailView +from api.views.members import ( + PublicMembersView, + PublicMemberDetailView, + PublicMemberAccessView, + PublicInvitesView, + PublicInviteDetailView, +) from api.views.auth import ( logout_view, health_check, @@ -44,6 +59,20 @@ 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("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/members/", PublicMembersView.as_view()), + path("public/v1/members//", PublicMemberDetailView.as_view()), + path("public/v1/members//access/", PublicMemberAccessView.as_view()), + path("public/v1/invites/", PublicInvitesView.as_view()), + path("public/v1/invites//", PublicInviteDetailView.as_view()), path( "public/v1/secrets/dynamic/", include("ee.integrations.secrets.dynamic.rest.urls"), 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..4dcdc8610 --- /dev/null +++ b/backend/tests/api/views/test_apps_api.py @@ -0,0 +1,771 @@ +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() + with patch("api.views.apps.user_is_org_member", return_value=True): + yield + + @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() + with patch("api.views.apps.user_is_org_member", return_value=True): + yield + + @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) + with patch("api.views.apps.user_is_org_member", return_value=True): + yield + + @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 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/api/views/test_members_api.py b/backend/tests/api/views/test_members_api.py new file mode 100644 index 000000000..8993cfda8 --- /dev/null +++ b/backend/tests/api/views/test_members_api.py @@ -0,0 +1,1085 @@ +import uuid +import pytest +from unittest.mock import Mock, MagicMock, patch +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework import status + +from api.views.members import ( + PublicMembersView, + PublicMemberDetailView, + PublicMemberAccessView, + PublicInvitesView, + PublicInviteDetailView, +) + + +# ──────────────────────────────────────────────────────────────────── +# Shared test helpers +# ──────────────────────────────────────────────────────────────────── + + +def _make_org(plan="PR"): + org = Mock() + org.id = uuid.uuid4() + org.plan = plan + org.organisation_id = org.id + return org + + +def _make_role(name="Developer"): + role = Mock() + role.id = uuid.uuid4() + role.name = name + return role + + +def _make_user(email="actor@example.com"): + user = Mock() + user.userId = uuid.uuid4() + user.id = user.userId + user.email = email + user.username = email.split("@")[0] + user.is_authenticated = True + user.is_active = True + return user + + +def _make_org_member(org=None, role_name="Owner", email="actor@example.com"): + org = org or _make_org() + member = Mock() + member.id = uuid.uuid4() + member.user = _make_user(email) + member.organisation = org + member.deleted_at = None + member.role = _make_role(role_name) + member.apps = MagicMock() + member.identity_key = "ab" * 32 + member.created_at = "2025-01-01T00:00:00Z" + member.updated_at = "2025-01-01T00:00:00Z" + return member + + +def _make_sa(org=None): + sa = Mock() + sa.id = uuid.uuid4() + sa.name = "deploy-bot" + org = org or _make_org() + sa.organisation = org + sa.organisation_id = org.id + sa.apps = MagicMock() + return sa + + +def _make_auth(org, auth_type="User", org_member=None, service_account=None): + return { + "token": "Bearer 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", acting_member=None): + factory = APIRequestFactory() + if method == "get": + request = factory.get(url) + elif method == "post": + request = factory.post(url, data=data or {}, format="json") + elif method == "put": + request = factory.put(url, data=data or {}, format="json") + elif method == "delete": + request = factory.delete(url) + else: + raise ValueError(f"Unknown method: {method}") + + if acting_member is None: + acting_member = _make_org_member(org=org, role_name=role_name) + sa = _make_sa(org) if auth_type == "ServiceAccount" else None + auth = _make_auth( + org, + auth_type=auth_type, + org_member=acting_member if auth_type == "User" else None, + service_account=sa, + ) + force_authenticate(request, user=acting_member.user, token=auth) + return request, acting_member, sa + + +# ════════════════════════════════════════════════════════════════════ +# PublicMembersView — List +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicMembersViewList: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicMembersView.as_view() + self.org = _make_org() + + @patch("api.views.members.OrganisationMemberSerializer") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_list_success(self, _ip, _throttle, _perm, mock_member_model, mock_serializer): + m1 = _make_org_member(org=self.org) + m2 = _make_org_member(org=self.org) + qs = MagicMock() + qs.filter.return_value.order_by.return_value = [m1, m2] + mock_member_model.objects.select_related.return_value = qs + mock_serializer.return_value.data = [{"id": str(m1.id)}, {"id": str(m2.id)}] + + request, _, _ = _build_request("get", "/public/v1/members/", self.org) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + @patch("api.views.members.OrganisationMemberSerializer") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_list_empty(self, _ip, _throttle, _perm, mock_member_model, mock_serializer): + qs = MagicMock() + qs.filter.return_value.order_by.return_value = [] + mock_member_model.objects.select_related.return_value = qs + mock_serializer.return_value.data = [] + + request, _, _ = _build_request("get", "/public/v1/members/", self.org) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_list_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request("get", "/public/v1/members/", self.org, role_name="Developer") + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicMembersView — Invite +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicMembersViewInvite: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicMembersView.as_view() + self.org = _make_org() + self.role = _make_role("Developer") + + def _mock_invite(self, role=None): + invite = Mock() + invite.id = uuid.uuid4() + invite.invitee_email = "new@example.com" + invite.role = role or self.role + invite.invited_by = None + invite.invited_by_service_account = None + invite.apps = MagicMock() + invite.created_at = "2025-01-01T00:00:00Z" + invite.expires_at = "2025-01-15T00:00:00Z" + invite.valid = True + return invite + + @patch("api.views.members.OrganisationMemberInviteSerializer") + @patch("api.views.members.log_audit_event") + @patch("api.views.members.get_actor_info", return_value=("user", "uid", {})) + @patch("api.views.members.get_resolver_request_meta", return_value=("127.0.0.1", "pytest")) + @patch("api.tasks.emails.send_invite_email_job") + @patch("api.views.members.can_add_account", return_value=True) + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.App") + @patch("api.views.members.Role") + @patch("api.views.members.role_has_permission", return_value=False) + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_success_user_auth( + self, _ip, _throttle, _perm, _global, _role_perm, + mock_role_model, mock_app_model, mock_member_model, mock_invite_model, + _quota, _email, _meta, _actor, _audit, mock_serializer, + ): + invite = self._mock_invite() + mock_role_model.objects.get.return_value = self.role + mock_member_model.objects.filter.return_value.exists.return_value = False + mock_invite_model.objects.filter.return_value.exists.return_value = False + mock_invite_model.objects.create.return_value = invite + mock_app_model.objects.filter.return_value = [] + mock_serializer.return_value.data = {"id": str(invite.id), "inviteeEmail": "new@example.com"} + + request, acting_member, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(self.role.id)}, + ) + response = self.view(request) + + assert response.status_code == status.HTTP_201_CREATED + create_kwargs = mock_invite_model.objects.create.call_args[1] + assert create_kwargs["invited_by"] == acting_member + assert create_kwargs["invited_by_service_account"] is None + + @patch("api.views.members.OrganisationMemberInviteSerializer") + @patch("api.views.members.log_audit_event") + @patch("api.views.members.get_actor_info", return_value=("sa", "sa-id", {"name": "deploy-bot"})) + @patch("api.views.members.get_resolver_request_meta", return_value=("127.0.0.1", "pytest")) + @patch("api.tasks.emails.send_invite_email_job") + @patch("api.views.members.can_add_account", return_value=True) + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.App") + @patch("api.views.members.Role") + @patch("api.views.members.role_has_permission", return_value=False) + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_success_sa_auth( + self, _ip, _throttle, _perm, _global, _role_perm, + mock_role_model, mock_app_model, mock_member_model, mock_invite_model, + _quota, _email, _meta, _actor, _audit, mock_serializer, + ): + invite = self._mock_invite() + mock_role_model.objects.get.return_value = self.role + mock_member_model.objects.filter.return_value.exists.return_value = False + mock_invite_model.objects.filter.return_value.exists.return_value = False + mock_invite_model.objects.create.return_value = invite + mock_app_model.objects.filter.return_value = [] + mock_serializer.return_value.data = {"id": str(invite.id)} + + request, _, sa = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(self.role.id)}, + auth_type="ServiceAccount", + ) + response = self.view(request) + + assert response.status_code == status.HTTP_201_CREATED + create_kwargs = mock_invite_model.objects.create.call_args[1] + assert create_kwargs["invited_by"] is None + assert create_kwargs["invited_by_service_account"] == sa + + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_missing_email_returns_400(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"role_id": str(self.role.id)}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "email" in response.data["error"] + + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_missing_role_id_returns_400(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com"}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "role_id" in response.data["error"] + + @patch("api.views.members.Role") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_role_not_found_returns_404(self, _ip, _throttle, _perm, mock_role_model): + from api.models import Role + mock_role_model.DoesNotExist = Role.DoesNotExist + mock_role_model.objects.get.side_effect = Role.DoesNotExist + + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(uuid.uuid4())}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.members.Role") + @patch("api.views.members.role_has_global_access", return_value=True) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_global_access_role_blocked_returns_400(self, _ip, _throttle, _perm, _global, mock_role_model): + admin_role = _make_role("Admin") + mock_role_model.objects.get.return_value = admin_role + + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(admin_role.id)}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "cannot be invited" in response.data["error"] + + @patch("api.views.members.Role") + @patch("api.views.members.role_has_permission", return_value=True) + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_sa_token_create_role_blocked_returns_400( + self, _ip, _throttle, _perm, _global, _role_perm, mock_role_model + ): + mock_role_model.objects.get.return_value = _make_role("Manager") + + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(uuid.uuid4())}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "service account tokens" in response.data["error"] + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.Role") + @patch("api.views.members.role_has_permission", return_value=False) + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_duplicate_member_returns_409( + self, _ip, _throttle, _perm, _global, _role_perm, mock_role_model, mock_member_model + ): + mock_role_model.objects.get.return_value = self.role + mock_member_model.objects.filter.return_value.exists.return_value = True + + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "existing@example.com", "role_id": str(self.role.id)}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_409_CONFLICT + assert "already a member" in response.data["error"] + + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.Role") + @patch("api.views.members.role_has_permission", return_value=False) + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_active_invite_exists_returns_409( + self, _ip, _throttle, _perm, _global, _role_perm, + mock_role_model, mock_member_model, mock_invite_model, + ): + mock_role_model.objects.get.return_value = self.role + mock_member_model.objects.filter.return_value.exists.return_value = False + mock_invite_model.objects.filter.return_value.exists.return_value = True + + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "pending@example.com", "role_id": str(self.role.id)}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_409_CONFLICT + assert "active invite" in response.data["error"] + + @patch("api.views.members.can_add_account", return_value=False) + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.Role") + @patch("api.views.members.role_has_permission", return_value=False) + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_quota_exceeded_returns_403( + self, _ip, _throttle, _perm, _global, _role_perm, + mock_role_model, mock_member_model, mock_invite_model, _quota, + ): + mock_role_model.objects.get.return_value = self.role + mock_member_model.objects.filter.return_value.exists.return_value = False + mock_invite_model.objects.filter.return_value.exists.return_value = False + + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(self.role.id)}, + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "quota" in response.data["error"] + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_invite_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "post", "/public/v1/members/", self.org, + data={"email": "new@example.com", "role_id": str(self.role.id)}, + role_name="Developer", + ) + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicMemberDetailView — Get +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicMemberDetailViewGet: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicMemberDetailView.as_view() + self.org = _make_org() + + @patch("api.views.members.OrganisationMemberSerializer") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_get_success(self, _ip, _throttle, _perm, mock_member_model, mock_serializer): + target = _make_org_member(org=self.org, email="target@example.com") + mock_member_model.objects.select_related.return_value.get.return_value = target + mock_serializer.return_value.data = {"id": str(target.id), "email": "target@example.com"} + + request, _, _ = _build_request("get", f"/public/v1/members/{target.id}/", self.org) + response = self.view(request, member_id=target.id) + + assert response.status_code == status.HTTP_200_OK + assert response.data["email"] == "target@example.com" + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_get_not_found_returns_404(self, _ip, _throttle, _perm, mock_member_model): + mock_member_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request, _, _ = _build_request("get", f"/public/v1/members/{uuid.uuid4()}/", self.org) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_get_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "get", f"/public/v1/members/{uuid.uuid4()}/", self.org, role_name="Developer" + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicMemberDetailView — Update role +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicMemberDetailViewUpdate: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicMemberDetailView.as_view() + self.org = _make_org() + + @patch("api.views.members.OrganisationMemberSerializer") + @patch("api.views.members.log_audit_event") + @patch("api.views.members.get_actor_info", return_value=("user", "uid", {})) + @patch("api.views.members.get_resolver_request_meta", return_value=("127.0.0.1", "pytest")) + @patch("api.views.members.Role") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_role_success( + self, _ip, _throttle, _perm, _global, + mock_member_model, mock_role_model, _meta, _actor, _audit, mock_serializer, + ): + target = _make_org_member(org=self.org, role_name="Developer", email="target@example.com") + new_role = _make_role("Manager") + mock_member_model.objects.select_related.return_value.get.return_value = target + mock_role_model.objects.get.return_value = new_role + mock_serializer.return_value.data = {"id": str(target.id), "role": {"name": "Manager"}} + + request, acting_member, _ = _build_request( + "put", f"/public/v1/members/{target.id}/", self.org, + data={"role_id": str(new_role.id)}, + ) + assert str(acting_member.id) != str(target.id) + + response = self.view(request, member_id=target.id) + + assert response.status_code == status.HTTP_200_OK + assert target.role == new_role + target.save.assert_called_once() + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_member_not_found_returns_404(self, _ip, _throttle, _perm, mock_member_model): + mock_member_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request, _, _ = _build_request( + "put", f"/public/v1/members/{uuid.uuid4()}/", self.org, + data={"role_id": str(uuid.uuid4())}, + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_self_returns_403(self, _ip, _throttle, _perm, mock_member_model): + acting_member = _make_org_member(org=self.org) + request, _, _ = _build_request( + "put", f"/public/v1/members/{acting_member.id}/", self.org, + data={"role_id": str(uuid.uuid4())}, + acting_member=acting_member, + ) + mock_member_model.objects.select_related.return_value.get.return_value = acting_member + + response = self.view(request, member_id=acting_member.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "own role" in response.data["error"] + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.role_has_global_access", side_effect=[True, False]) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_global_access_member_without_global_access_returns_403( + self, _ip, _throttle, _perm, _global, mock_member_model + ): + # target has global-access role; acting member does not + target = _make_org_member(org=self.org, role_name="Admin", email="admin@example.com") + mock_member_model.objects.select_related.return_value.get.return_value = target + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/", self.org, + data={"role_id": str(uuid.uuid4())}, + role_name="Developer", + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "global access" in response.data["error"] + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_missing_role_id_returns_400(self, _ip, _throttle, _perm, _global, mock_member_model): + target = _make_org_member(org=self.org, email="target@example.com") + mock_member_model.objects.select_related.return_value.get.return_value = target + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/", self.org, + data={}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "role_id" in response.data["error"] + + @patch("api.views.members.Role") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.role_has_global_access", return_value=False) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_owner_role_blocked_returns_403( + self, _ip, _throttle, _perm, _global, mock_member_model, mock_role_model + ): + target = _make_org_member(org=self.org, email="target@example.com") + owner_role = _make_role("Owner") + mock_member_model.objects.select_related.return_value.get.return_value = target + mock_role_model.objects.get.return_value = owner_role + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/", self.org, + data={"role_id": str(owner_role.id)}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "Owner" in response.data["error"] + + @patch("api.views.members.Role") + @patch("api.views.members.OrganisationMember") + # side_effect order: member.role=False (skip global-member check), new_role=True, acting.role=False + @patch("api.views.members.role_has_global_access", side_effect=[False, True, False]) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_global_role_without_global_access_returns_403( + self, _ip, _throttle, _perm, _global, mock_member_model, mock_role_model + ): + target = _make_org_member(org=self.org, email="target@example.com") + admin_role = _make_role("Admin") + mock_member_model.objects.select_related.return_value.get.return_value = target + mock_role_model.objects.get.return_value = admin_role + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/", self.org, + data={"role_id": str(admin_role.id)}, + role_name="Developer", + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.members.Role") + @patch("api.views.members.OrganisationMember") + # SA auth skips the self-check block; role_has_global_access called once (new_role=True) + @patch("api.views.members.role_has_global_access", side_effect=[True]) + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_sa_cannot_assign_global_access_role_returns_403( + self, _ip, _throttle, _perm, _global, mock_member_model, mock_role_model + ): + target = _make_org_member(org=self.org, email="target@example.com") + admin_role = _make_role("Admin") + mock_member_model.objects.select_related.return_value.get.return_value = target + mock_role_model.objects.get.return_value = admin_role + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/", self.org, + data={"role_id": str(admin_role.id)}, + auth_type="ServiceAccount", + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_update_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "put", f"/public/v1/members/{uuid.uuid4()}/", self.org, + data={"role_id": str(uuid.uuid4())}, + role_name="Developer", + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicMemberDetailView — Delete +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicMemberDetailViewDelete: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicMemberDetailView.as_view() + self.org = _make_org() + + @patch("api.views.members.log_audit_event") + @patch("api.views.members.get_actor_info", return_value=("user", "uid", {})) + @patch("api.views.members.get_resolver_request_meta", return_value=("127.0.0.1", "pytest")) + @patch("api.views.members.CLOUD_HOSTED", False) + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_delete_success_soft_deletes_member( + self, _ip, _throttle, _perm, mock_member_model, _meta, _actor, _audit + ): + target = _make_org_member(org=self.org, email="target@example.com") + mock_member_model.objects.select_related.return_value.get.return_value = target + + request, acting_member, _ = _build_request( + "delete", f"/public/v1/members/{target.id}/", self.org, + ) + assert str(acting_member.id) != str(target.id) + + response = self.view(request, member_id=target.id) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert target.deleted_at is not None + target.save.assert_called_once() + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_delete_not_found_returns_404(self, _ip, _throttle, _perm, mock_member_model): + mock_member_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request, _, _ = _build_request( + "delete", f"/public/v1/members/{uuid.uuid4()}/", self.org, + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_delete_self_returns_403(self, _ip, _throttle, _perm, mock_member_model): + acting_member = _make_org_member(org=self.org) + request, _, _ = _build_request( + "delete", f"/public/v1/members/{acting_member.id}/", self.org, + acting_member=acting_member, + ) + mock_member_model.objects.select_related.return_value.get.return_value = acting_member + + response = self.view(request, member_id=acting_member.id) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "yourself" in response.data["error"] + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_delete_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "delete", f"/public/v1/members/{uuid.uuid4()}/", self.org, + role_name="Developer", + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicMemberAccessView — Access management +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicMemberAccessView: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicMemberAccessView.as_view() + self.org = _make_org() + + def _make_sse_app(self): + app = Mock() + app.id = uuid.uuid4() + app.name = "test-app" + app.sse_enabled = True + app.organisation = self.org + return app + + def _make_env(self, app): + env = Mock() + env.id = uuid.uuid4() + env.app = app + return env + + @patch("api.views.members.OrganisationMemberSerializer") + @patch("api.views.members.log_audit_event") + @patch("api.views.members.get_actor_info", return_value=("user", "uid", {})) + @patch("api.views.members.get_resolver_request_meta", return_value=("127.0.0.1", "pytest")) + @patch("api.views.members.EnvironmentKey") + @patch("api.views.members.ServerEnvironmentKey") + @patch("api.views.members.Environment") + @patch("api.views.members.App") + @patch("api.views.members.transaction") + @patch("api.views.members._wrap_env_secrets_for_key", return_value=("wrapped_seed", "wrapped_salt")) + @patch("api.views.members.decrypt_asymmetric", return_value="decrypted_value") + @patch("api.views.members.get_server_keypair", return_value=(b"\x00" * 32, b"\x01" * 32)) + @patch("api.views.members._ed25519_pk_to_curve25519", return_value=b"\x02" * 32) + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_grant_success( + self, _ip, _throttle, _perm, + mock_member_model, _ed25519, _server_kp, _decrypt, _wrap, + mock_txn, mock_app_model, mock_env_model, mock_sek_model, mock_ek_model, + _meta, _actor, _audit, mock_serializer, + ): + app = self._make_sse_app() + env = self._make_env(app) + target = _make_org_member(org=self.org, email="target@example.com") + + mock_member_model.objects.select_related.return_value.get.return_value = target + mock_app_model.objects.get.return_value = app + mock_env_model.objects.filter.return_value.values_list.return_value = [env.id] + mock_env_model.objects.get.return_value = env + + # No current app access + target.apps.filter.return_value = [] + # No current env keys + mock_ek_model.objects.filter.return_value = [] + + # SEK exists + sek = Mock() + sek.wrapped_seed = "ws" + sek.wrapped_salt = "wsa" + sek.identity_key = "ik" + mock_sek_model.objects.get.return_value = sek + + mock_serializer.return_value.data = {"id": str(target.id)} + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/access/", self.org, + data={"apps": [{"id": str(app.id), "environments": [str(env.id)]}]}, + ) + response = self.view(request, member_id=target.id) + + assert response.status_code == status.HTTP_200_OK + mock_ek_model.objects.bulk_create.assert_called_once() + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_member_not_found_returns_404(self, _ip, _throttle, _perm, mock_member_model): + mock_member_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request, _, _ = _build_request( + "put", f"/public/v1/members/{uuid.uuid4()}/access/", self.org, + data={"apps": []}, + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_missing_identity_key_returns_400(self, _ip, _throttle, _perm, mock_member_model): + target = _make_org_member(org=self.org) + target.identity_key = None + mock_member_model.objects.select_related.return_value.get.return_value = target + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/access/", self.org, + data={"apps": []}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "identity key" in response.data["error"] + + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_missing_apps_field_returns_400(self, _ip, _throttle, _perm, mock_member_model): + target = _make_org_member(org=self.org) + mock_member_model.objects.select_related.return_value.get.return_value = target + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/access/", self.org, + data={}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "apps" in response.data["error"] + + @patch("api.views.members.App") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_non_sse_app_returns_400(self, _ip, _throttle, _perm, mock_member_model, mock_app_model): + target = _make_org_member(org=self.org) + mock_member_model.objects.select_related.return_value.get.return_value = target + non_sse_app = Mock() + non_sse_app.id = uuid.uuid4() + non_sse_app.name = "no-sse" + non_sse_app.sse_enabled = False + mock_app_model.objects.get.return_value = non_sse_app + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/access/", self.org, + data={"apps": [{"id": str(non_sse_app.id), "environments": [str(uuid.uuid4())]}]}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "SSE" in response.data["error"] + + @patch("api.views.members.App") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_empty_environments_returns_400(self, _ip, _throttle, _perm, mock_member_model, mock_app_model): + target = _make_org_member(org=self.org) + mock_member_model.objects.select_related.return_value.get.return_value = target + app = self._make_sse_app() + mock_app_model.objects.get.return_value = app + + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/access/", self.org, + data={"apps": [{"id": str(app.id), "environments": []}]}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "empty" in response.data["error"] + + @patch("api.views.members.Environment") + @patch("api.views.members.App") + @patch("api.views.members.OrganisationMember") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_invalid_environment_returns_404( + self, _ip, _throttle, _perm, mock_member_model, mock_app_model, mock_env_model + ): + target = _make_org_member(org=self.org) + mock_member_model.objects.select_related.return_value.get.return_value = target + app = self._make_sse_app() + mock_app_model.objects.get.return_value = app + # Return empty queryset — no valid envs + mock_env_model.objects.filter.return_value.values_list.return_value = [] + + fake_env_id = str(uuid.uuid4()) + request, _, _ = _build_request( + "put", f"/public/v1/members/{target.id}/access/", self.org, + data={"apps": [{"id": str(app.id), "environments": [fake_env_id]}]}, + ) + response = self.view(request, member_id=target.id) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert fake_env_id in response.data["error"] + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_access_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "put", f"/public/v1/members/{uuid.uuid4()}/access/", self.org, + data={"apps": []}, + role_name="Developer", + ) + response = self.view(request, member_id=uuid.uuid4()) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicInvitesView — List +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicInvitesView: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicInvitesView.as_view() + self.org = _make_org() + + @patch("api.views.members.OrganisationMemberInviteSerializer") + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_list_invites_success(self, _ip, _throttle, _perm, mock_invite_model, mock_serializer): + qs = MagicMock() + qs.filter.return_value.order_by.return_value = [Mock(), Mock()] + mock_invite_model.objects.select_related.return_value = qs + mock_serializer.return_value.data = [{"id": "inv-1"}, {"id": "inv-2"}] + + request, _, _ = _build_request("get", "/public/v1/invites/", self.org) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_list_invites_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request("get", "/public/v1/invites/", self.org, role_name="Developer") + response = self.view(request) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ════════════════════════════════════════════════════════════════════ +# PublicInviteDetailView — Cancel +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicInviteDetailView: + + @pytest.fixture(autouse=True) + def setup(self, settings): + settings.DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + } + self.view = PublicInviteDetailView.as_view() + self.org = _make_org() + + @patch("api.views.members.log_audit_event") + @patch("api.views.members.get_actor_info", return_value=("user", "uid", {})) + @patch("api.views.members.get_resolver_request_meta", return_value=("127.0.0.1", "pytest")) + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_cancel_invite_success( + self, _ip, _throttle, _perm, mock_invite_model, _meta, _actor, _audit + ): + invite = Mock() + invite.id = uuid.uuid4() + invite.invitee_email = "pending@example.com" + invite.role = _make_role("Developer") + mock_invite_model.objects.select_related.return_value.get.return_value = invite + + request, _, _ = _build_request( + "delete", f"/public/v1/invites/{invite.id}/", self.org, + ) + response = self.view(request, invite_id=invite.id) + + assert response.status_code == status.HTTP_204_NO_CONTENT + invite.delete.assert_called_once() + + @patch("api.views.members.OrganisationMemberInvite") + @patch("api.views.members.user_has_permission", return_value=True) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_cancel_invite_not_found_returns_404(self, _ip, _throttle, _perm, mock_invite_model): + mock_invite_model.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist + + request, _, _ = _build_request( + "delete", f"/public/v1/invites/{uuid.uuid4()}/", self.org, + ) + response = self.view(request, invite_id=uuid.uuid4()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("api.views.members.user_has_permission", return_value=False) + @patch("api.views.members.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.members.IsIPAllowed.has_permission", return_value=True) + def test_cancel_invite_no_permission_returns_403(self, _ip, _throttle, _perm): + request, _, _ = _build_request( + "delete", f"/public/v1/invites/{uuid.uuid4()}/", self.org, + role_name="Developer", + ) + response = self.view(request, invite_id=uuid.uuid4()) + assert response.status_code == status.HTTP_403_FORBIDDEN 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"] 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}" + ) diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 56fd55adb..717bf7865 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -123,14 +123,15 @@ 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 GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n invitedByServiceAccount {\n id\n name\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, "query GetOrgLicense($organisationId: ID!) {\n organisationLicense(organisationId: $organisationId) {\n id\n customerName\n issuedAt\n expiresAt\n activatedAt\n plan\n seats\n tokens\n }\n}": typeof types.GetOrgLicenseDocument, "query GetOrganisationMembers($organisationId: ID!, $role: [String]) {\n organisationMembers(organisationId: $organisationId, role: $role) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n }\n}": typeof types.GetOrganisationMembersDocument, "query GetOrganisationPlan($organisationId: ID!) {\n organisationPlan(organisationId: $organisationId) {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n seatLimit\n appCount\n }\n}": typeof types.GetOrganisationPlanDocument, "query GetRoles($orgId: ID!) {\n roles(orgId: $orgId) {\n id\n name\n description\n color\n permissions\n isDefault\n }\n}": typeof types.GetRolesDocument, - "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n apps {\n id\n name\n }\n }\n}": typeof types.VerifyInviteDocument, + "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n invitedByServiceAccount {\n id\n name\n }\n apps {\n id\n name\n }\n }\n}": typeof types.VerifyInviteDocument, "query GetDynamicSecrets($orgId: ID!, $appId: ID, $envId: ID, $path: String) {\n dynamicSecrets(orgId: $orgId, appId: $appId, envId: $envId, path: $path) {\n id\n name\n environment {\n id\n name\n index\n app {\n id\n name\n }\n }\n path\n description\n provider\n config {\n ... on AWSConfigType {\n usernameTemplate\n iamPath\n }\n }\n keyMap {\n id\n keyName\n masked\n }\n defaultTtlSeconds\n maxTtlSeconds\n authentication {\n id\n name\n }\n createdAt\n }\n}": typeof types.GetDynamicSecretsDocument, "query GetDynamicSecretProviders {\n dynamicSecretProviders {\n id\n name\n credentials\n configMap\n }\n}": typeof types.GetDynamicSecretProvidersDocument, "query GetDynamicSecretLeases($secretId: ID!, $orgId: ID!) {\n dynamicSecrets(secretId: $secretId, orgId: $orgId) {\n id\n leases {\n id\n name\n ttl\n createdAt\n expiresAt\n revokedAt\n status\n organisationMember {\n id\n fullName\n email\n avatarUrl\n self\n }\n serviceAccount {\n id\n name\n }\n events {\n id\n eventType\n createdAt\n metadata\n ipAddress\n userAgent\n organisationMember {\n id\n fullName\n email\n avatarUrl\n self\n }\n serviceAccount {\n id\n name\n }\n }\n }\n }\n}": typeof types.GetDynamicSecretLeasesDocument, @@ -284,14 +285,15 @@ const documents: 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 GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n invitedByServiceAccount {\n id\n name\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, "query GetOrgLicense($organisationId: ID!) {\n organisationLicense(organisationId: $organisationId) {\n id\n customerName\n issuedAt\n expiresAt\n activatedAt\n plan\n seats\n tokens\n }\n}": types.GetOrgLicenseDocument, "query GetOrganisationMembers($organisationId: ID!, $role: [String]) {\n organisationMembers(organisationId: $organisationId, role: $role) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n }\n}": types.GetOrganisationMembersDocument, "query GetOrganisationPlan($organisationId: ID!) {\n organisationPlan(organisationId: $organisationId) {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n seatLimit\n appCount\n }\n}": types.GetOrganisationPlanDocument, "query GetRoles($orgId: ID!) {\n roles(orgId: $orgId) {\n id\n name\n description\n color\n permissions\n isDefault\n }\n}": types.GetRolesDocument, - "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n apps {\n id\n name\n }\n }\n}": types.VerifyInviteDocument, + "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n invitedByServiceAccount {\n id\n name\n }\n apps {\n id\n name\n }\n }\n}": types.VerifyInviteDocument, "query GetDynamicSecrets($orgId: ID!, $appId: ID, $envId: ID, $path: String) {\n dynamicSecrets(orgId: $orgId, appId: $appId, envId: $envId, path: $path) {\n id\n name\n environment {\n id\n name\n index\n app {\n id\n name\n }\n }\n path\n description\n provider\n config {\n ... on AWSConfigType {\n usernameTemplate\n iamPath\n }\n }\n keyMap {\n id\n keyName\n masked\n }\n defaultTtlSeconds\n maxTtlSeconds\n authentication {\n id\n name\n }\n createdAt\n }\n}": types.GetDynamicSecretsDocument, "query GetDynamicSecretProviders {\n dynamicSecretProviders {\n id\n name\n credentials\n configMap\n }\n}": types.GetDynamicSecretProvidersDocument, "query GetDynamicSecretLeases($secretId: ID!, $orgId: ID!) {\n dynamicSecrets(secretId: $secretId, orgId: $orgId) {\n id\n leases {\n id\n name\n ttl\n createdAt\n expiresAt\n revokedAt\n status\n organisationMember {\n id\n fullName\n email\n avatarUrl\n self\n }\n serviceAccount {\n id\n name\n }\n events {\n id\n eventType\n createdAt\n metadata\n ipAddress\n userAgent\n organisationMember {\n id\n fullName\n email\n avatarUrl\n self\n }\n serviceAccount {\n id\n name\n }\n }\n }\n }\n}": types.GetDynamicSecretLeasesDocument, @@ -786,6 +788,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. */ @@ -793,7 +799,7 @@ export function graphql(source: "query GetGlobalAccessUsers($organisationId: ID! /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "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 documents)["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}"]; +export function graphql(source: "query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n invitedByServiceAccount {\n id\n name\n }\n inviteeEmail\n role {\n id\n name\n description\n color\n }\n }\n}"): (typeof documents)["query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n invitedByServiceAccount {\n id\n name\n }\n inviteeEmail\n role {\n id\n name\n description\n color\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -817,7 +823,7 @@ export function graphql(source: "query GetRoles($orgId: ID!) {\n roles(orgId: $ /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n apps {\n id\n name\n }\n }\n}"): (typeof documents)["query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n apps {\n id\n name\n }\n }\n}"]; +export function graphql(source: "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n invitedByServiceAccount {\n id\n name\n }\n apps {\n id\n name\n }\n }\n}"): (typeof documents)["query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n invitedByServiceAccount {\n id\n name\n }\n apps {\n id\n name\n }\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 f00798e51..71c373ee9 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -125,6 +125,52 @@ export enum ApiActivatedPhaseLicensePlanChoices { Pr = 'PR' } +/** An enumeration. */ +export enum ApiAuditEventActorTypeChoices { + /** ServiceAccount */ + Sa = 'SA', + /** User */ + User = 'USER' +} + +/** An enumeration. */ +export enum ApiAuditEventEventTypeChoices { + /** Access */ + A = 'A', + /** Create */ + C = 'C', + /** Delete */ + D = 'D', + /** Read */ + R = 'R', + /** Update */ + U = 'U' +} + +/** An enumeration. */ +export enum ApiAuditEventResourceTypeChoices { + /** App */ + App = 'APP', + /** Environment */ + Env = 'ENV', + /** Invite */ + Invite = 'INVITE', + /** OrganisationMember */ + Member = 'MEMBER', + /** UserToken */ + Pat = 'PAT', + /** NetworkAccessPolicy */ + Policy = 'POLICY', + /** Role */ + Role = 'ROLE', + /** ServiceAccount */ + Sa = 'SA', + /** ServiceAccountToken */ + SaToken = 'SA_TOKEN', + /** ServiceToken */ + SvcToken = 'SVC_TOKEN' +} + /** An enumeration. */ export enum ApiDynamicSecretLeaseEventEventTypeChoices { /** Active */ @@ -263,6 +309,30 @@ export type AppType = { wrappedKeyShare: Scalars['String']['output']; }; +export type AuditEventType = { + __typename?: 'AuditEventType'; + actorId: Scalars['String']['output']; + actorMetadata: Scalars['JSONString']['output']; + actorType: ApiAuditEventActorTypeChoices; + description: Scalars['String']['output']; + eventType: ApiAuditEventEventTypeChoices; + id: Scalars['String']['output']; + ipAddress?: Maybe; + newValues?: Maybe; + oldValues?: Maybe; + resourceId: Scalars['String']['output']; + resourceMetadata: Scalars['JSONString']['output']; + resourceType: ApiAuditEventResourceTypeChoices; + timestamp: Scalars['DateTime']['output']; + userAgent: Scalars['String']['output']; +}; + +export type AuditLogsResponseType = { + __typename?: 'AuditLogsResponseType'; + count?: Maybe; + logs?: Maybe>>; +}; + export type AwsCredentialsType = { __typename?: 'AwsCredentialsType'; accessKeyId?: Maybe; @@ -1740,7 +1810,8 @@ export type OrganisationMemberInviteType = { createdAt?: Maybe; expiresAt: Scalars['DateTime']['output']; id: Scalars['String']['output']; - invitedBy: OrganisationMemberType; + invitedBy?: Maybe; + invitedByServiceAccount?: Maybe; inviteeEmail: Scalars['String']['output']; organisation: OrganisationType; role?: Maybe; @@ -1875,6 +1946,7 @@ export type Query = { appServiceAccounts?: Maybe>>; appUsers?: Maybe>>; apps?: Maybe>>; + auditLogs?: Maybe; awsSecrets?: Maybe>>; awsStsEndpoints?: Maybe>>; azureKvSecrets?: Maybe>>; @@ -1966,6 +2038,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; }; @@ -3663,6 +3748,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: ApiAuditEventEventTypeChoices, resourceType: ApiAuditEventResourceTypeChoices, resourceId: string, actorType: ApiAuditEventActorTypeChoices, actorId: string, actorMetadata: any, resourceMetadata: any, 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']; }>; @@ -3675,7 +3775,7 @@ export type GetInvitesQueryVariables = Exact<{ }>; -export type GetInvitesQuery = { __typename?: 'Query', organisationInvites?: Array<{ __typename?: 'OrganisationMemberInviteType', id: string, createdAt?: any | null, expiresAt: any, inviteeEmail: string, invitedBy: { __typename?: 'OrganisationMemberType', email?: string | null, fullName?: string | null, self?: boolean | null }, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null } | null } | null> | null }; +export type GetInvitesQuery = { __typename?: 'Query', organisationInvites?: Array<{ __typename?: 'OrganisationMemberInviteType', id: string, createdAt?: any | null, expiresAt: any, inviteeEmail: string, invitedBy?: { __typename?: 'OrganisationMemberType', email?: string | null, fullName?: string | null, self?: boolean | null } | null, invitedByServiceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string } | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null } | null } | null> | null }; export type GetLicenseDataQueryVariables = Exact<{ [key: string]: never; }>; @@ -3716,7 +3816,7 @@ export type VerifyInviteQueryVariables = Exact<{ }>; -export type VerifyInviteQuery = { __typename?: 'Query', validateInvite?: { __typename?: 'OrganisationMemberInviteType', id: string, inviteeEmail: string, organisation: { __typename?: 'OrganisationType', id: string, name: string }, invitedBy: { __typename?: 'OrganisationMemberType', fullName?: string | null, email?: string | null }, apps: Array<{ __typename?: 'AppMembershipType', id: string, name: string }> } | null }; +export type VerifyInviteQuery = { __typename?: 'Query', validateInvite?: { __typename?: 'OrganisationMemberInviteType', id: string, inviteeEmail: string, organisation: { __typename?: 'OrganisationType', id: string, name: string }, invitedBy?: { __typename?: 'OrganisationMemberType', fullName?: string | null, email?: string | null } | null, invitedByServiceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string } | null, apps: Array<{ __typename?: 'AppMembershipType', id: string, name: string }> } | null }; export type GetDynamicSecretsQueryVariables = Exact<{ orgId: Scalars['ID']['input']; @@ -4142,14 +4242,15 @@ 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 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":"invitedByServiceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"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; export const GetOrgLicenseDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrgLicense"},"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":"organisationLicense"},"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":"customerName"}},{"kind":"Field","name":{"kind":"Name","value":"issuedAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"activatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"seats"}},{"kind":"Field","name":{"kind":"Name","value":"tokens"}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMembers"},"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":"role"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}}],"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastLogin"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationPlan"},"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":"organisationPlan"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"maxUsers"}},{"kind":"Field","name":{"kind":"Name","value":"maxApps"}},{"kind":"Field","name":{"kind":"Name","value":"maxEnvsPerApp"}},{"kind":"Field","name":{"kind":"Name","value":"seatsUsed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"seatLimit"}},{"kind":"Field","name":{"kind":"Name","value":"appCount"}}]}}]}}]} as unknown as DocumentNode; export const GetRolesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRoles"},"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":"roles"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}},{"kind":"Field","name":{"kind":"Name","value":"isDefault"}}]}}]}}]} as unknown as DocumentNode; -export const VerifyInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"VerifyInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateInvite"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organisation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"apps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const VerifyInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"VerifyInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateInvite"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organisation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedByServiceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"apps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetDynamicSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDynamicSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dynamicSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AWSConfigType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"usernameTemplate"}},{"kind":"Field","name":{"kind":"Name","value":"iamPath"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"keyMap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"keyName"}},{"kind":"Field","name":{"kind":"Name","value":"masked"}}]}},{"kind":"Field","name":{"kind":"Name","value":"defaultTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"maxTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"authentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; export const GetDynamicSecretProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDynamicSecretProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dynamicSecretProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}},{"kind":"Field","name":{"kind":"Name","value":"configMap"}}]}}]}}]} as unknown as DocumentNode; export const GetDynamicSecretLeasesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDynamicSecretLeases"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"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":"dynamicSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"secretId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretId"}}},{"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":"leases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"ttl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"organisationMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"}},{"kind":"Field","name":{"kind":"Name","value":"organisationMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 6b1227c41..3cc996801 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -15,6 +15,7 @@ type Query { apps(organisationId: ID, appId: ID): [AppType] kmsLogs(appId: ID, start: BigInt, end: BigInt): KMSLogsResponseType secretLogs(appId: ID, start: BigInt, end: BigInt, eventTypes: [String], memberId: ID, 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, environmentId: ID, memberId: ID, memberType: MemberType): [EnvironmentType] appUsers(appId: ID): [OrganisationMemberType] @@ -675,7 +676,8 @@ type OrganisationMemberInviteType { organisation: OrganisationType! apps: [AppMembershipType!]! role: RoleType - invitedBy: OrganisationMemberType! + invitedBy: OrganisationMemberType + invitedByServiceAccount: ServiceAccountType inviteeEmail: String! valid: Boolean! createdAt: DateTime @@ -745,6 +747,88 @@ enum MemberType { SERVICE } +type AuditLogsResponseType { + logs: [AuditEventType] + count: Int +} + +type AuditEventType { + id: String! + eventType: ApiAuditEventEventTypeChoices! + resourceType: ApiAuditEventResourceTypeChoices! + resourceId: String! + actorType: ApiAuditEventActorTypeChoices! + actorId: String! + actorMetadata: JSONString! + resourceMetadata: JSONString! + oldValues: JSONString + newValues: JSONString + description: String! + ipAddress: String + userAgent: String! + timestamp: DateTime! +} + +"""An enumeration.""" +enum ApiAuditEventEventTypeChoices { + """Create""" + C + + """Read""" + R + + """Update""" + U + + """Delete""" + D + + """Access""" + A +} + +"""An enumeration.""" +enum ApiAuditEventResourceTypeChoices { + """App""" + APP + + """Environment""" + ENV + + """Role""" + ROLE + + """ServiceAccount""" + SA + + """OrganisationMember""" + MEMBER + + """NetworkAccessPolicy""" + POLICY + + """UserToken""" + PAT + + """ServiceAccountToken""" + SA_TOKEN + + """ServiceToken""" + SVC_TOKEN + + """Invite""" + INVITE +} + +"""An enumeration.""" +enum ApiAuditEventActorTypeChoices { + """User""" + USER + + """ServiceAccount""" + SA +} + type ChartDataPointType { index: Int date: BigInt diff --git a/frontend/app/[team]/access/members/page.tsx b/frontend/app/[team]/access/members/page.tsx index 225890c5d..186d8407b 100644 --- a/frontend/app/[team]/access/members/page.tsx +++ b/frontend/app/[team]/access/members/page.tsx @@ -176,13 +176,17 @@ export default function Members({ params }: { params: { team: string } }) {
{invite.inviteeEmail}{' '} - - (invited by{' '} - {invite.invitedBy.self - ? 'You' - : invite.invitedBy.fullName || invite.invitedBy.email} - ) - + {(invite.invitedBy || invite.invitedByServiceAccount) && ( + + (invited by{' '} + {invite.invitedBy + ? invite.invitedBy.self + ? 'You' + : invite.invitedBy.fullName || invite.invitedBy.email + : invite.invitedByServiceAccount!.name} + ) + + )}
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 3fdcb14be..af56380ae 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 d79240fb6..93e08ebc2 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 22985c394..cec247f4e 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 3cefc2e2d..2c02a5d73 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 diff --git a/frontend/app/[team]/logs/page.tsx b/frontend/app/[team]/logs/page.tsx new file mode 100644 index 000000000..acc7972fb --- /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/app/invite/[invite]/page.tsx b/frontend/app/invite/[invite]/page.tsx index a086fbc3b..93953853e 100644 --- a/frontend/app/invite/[invite]/page.tsx +++ b/frontend/app/invite/[invite]/page.tsx @@ -206,7 +206,9 @@ export default function Invite({ params }: { params: { invite: string } }) {

You have been invited by{' '} - {invite.invitedBy.fullName || invite.invitedBy.email} + {invite.invitedBy + ? invite.invitedBy.fullName || invite.invitedBy.email + : invite.invitedByServiceAccount?.name ?? invite.organisation.name} {' '} to join the{' '} diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 6e885f305..3a9d16463 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' @@ -248,6 +249,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..5278c679d --- /dev/null +++ b/frontend/components/logs/AuditLogs.tsx @@ -0,0 +1,1146 @@ +'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 { + ApiAuditEventActorTypeChoices, + ApiAuditEventResourceTypeChoices, + 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, +} 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: 'invite', label: 'Invites', resourceType: 'invite' }, + { 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 = { + [ApiAuditEventResourceTypeChoices.App]: 'App', + [ApiAuditEventResourceTypeChoices.Env]: 'Environment', + [ApiAuditEventResourceTypeChoices.Role]: 'Role', + [ApiAuditEventResourceTypeChoices.Sa]: 'Service Account', + [ApiAuditEventResourceTypeChoices.Member]: 'Member', + [ApiAuditEventResourceTypeChoices.Policy]: 'Network Policy', + [ApiAuditEventResourceTypeChoices.Pat]: 'Personal Access Token', + [ApiAuditEventResourceTypeChoices.SaToken]: 'Service Account Token', + [ApiAuditEventResourceTypeChoices.SvcToken]: 'Service Token', + [ApiAuditEventResourceTypeChoices.Invite]: 'Invite', + } + 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 === ApiAuditEventResourceTypeChoices.App) return `/${team}/apps/${id}` + if (rt === ApiAuditEventResourceTypeChoices.Env && resourceMeta?.app_id) + return `/${team}/apps/${resourceMeta.app_id}/environments/${id}` + if (rt === ApiAuditEventResourceTypeChoices.Role) return `/${team}/access/roles` + if (rt === ApiAuditEventResourceTypeChoices.Sa) return `/${team}/access/service-accounts/${id}` + if (rt === ApiAuditEventResourceTypeChoices.Member) return `/${team}/access/members/${id}` + if (rt === ApiAuditEventResourceTypeChoices.Policy) return `/${team}/access/network` + if (rt === ApiAuditEventResourceTypeChoices.Pat) return `/${team}/access/authentication` + if (rt === ApiAuditEventResourceTypeChoices.SaToken && resourceMeta?.service_account_id) + return `/${team}/access/service-accounts/${resourceMeta.service_account_id}` + if (rt === ApiAuditEventResourceTypeChoices.SvcToken && resourceMeta?.app_id) + return `/${team}/apps/${resourceMeta.app_id}` + + return null +} + +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 isSaActor = log.actorType === ApiAuditEventActorTypeChoices.Sa + const sa = isSaActor ? serviceAccounts.find((s) => s.id === log.actorId) : null + + const actorDisplayName = member + ? member.fullName || member.email || 'User' + : sa + ? sa.name + : isSaActor + ? actorMeta?.name || 'Service Account' + : actorMeta?.email || actorMeta?.username || 'User' + + const ActorAvatar = ({ size = 'sm' }: { size?: 'sm' | 'md' }) => + member ? ( + + ) : sa ? ( + + ) : isSaActor ? ( + + ) : ( + + ) + + const relativeTimeStamp = relativeTimeFromDates(new Date(log.timestamp)) + const verboseTimeStamp = new Date(log.timestamp).toISOString() + + const resourceLink = getResourceLink(log, resourceMeta, team) + + // Resolve resource to a member or SA entity when applicable + const resourceMember = + log.resourceType === ApiAuditEventResourceTypeChoices.Member + ? members.find((m) => m.id === log.resourceId) + : null + const resourceSa = + log.resourceType === ApiAuditEventResourceTypeChoices.Sa + ? serviceAccounts.find((s) => s.id === log.resourceId) + : null + + const resourceDisplayLabel = resourceMember + ? resourceMember.fullName || resourceMember.email || 'Member' + : resourceSa + ? resourceSa.name || 'Service Account' + : getResourceTypeLabel(log.resourceType) + + const LogField = ({ label, children }: { label: string; children: React.ReactNode }) => ( +

+ {label}: + {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'] + 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)} + + ))} +
+ ) + + /** Resolve an item to a display chip — tries member/SA lookup, falls back to item.name */ + const resolveItemDisplay = (item: { id: string; name: string }) => { + const m = members.find((m) => m.id === item.id) + if (m) + return ( + + + {m.fullName || m.email} + + ) + const s = serviceAccounts.find((s) => s.id === item.id) + if (s) + return ( + + + {s.name} + + ) + return {item.name} + } + + /** Render detail objects with optional env scope (members, SAs, or apps) */ + const MemberDetailList = ({ + items, + variant, + }: { + items: Array<{ + id: string + name: string + type?: string + env_scope?: string[] + environments?: string[] + }> + variant: 'added' | 'removed' + }) => ( +
+ {items.map((item) => ( +
+ + {variant === 'removed' ? '- ' : '+ '} + {resolveItemDisplay(item)} + + {(item.env_scope || item.environments) && + (item.env_scope || item.environments)!.length > 0 && ( + + ({(item.env_scope || item.environments)!.join(', ')}) + + )} +
+ ))} +
+ ) + + 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 humanizeKey = (key: string): string => { + const labels: Record = { + apps_granted: 'Apps Granted', + apps_revoked: 'Apps Revoked', + members_added: 'Members Added', + members_removed: 'Members Removed', + envs_added: 'Environments Added', + envs_removed: 'Environments Removed', + env_scope: 'Environment Scope', + access_updated: 'Access Updated', + } + return labels[key] || key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + } + + 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] + + // Render member detail objects (from bulk add/remove with env scope) + if (isMemberDetailList(oldVal) || isMemberDetailList(newVal)) { + return ( +
+ {humanizeKey(key)} + {oldVal !== undefined && Array.isArray(oldVal) && ( + + )} + {newVal !== undefined && Array.isArray(newVal) && ( + + )} +
+ ) + } + + // Render env scope change objects ({app, environments}) + const isEnvScopeList = (v: any) => + Array.isArray(v) && + v.length > 0 && + v.every((x: any) => typeof x === 'object' && 'app' in x && 'environments' in x) + if (isEnvScopeList(oldVal) || isEnvScopeList(newVal)) { + const renderEnvScopes = ( + items: Array<{ app: string; environments: string[] }>, + variant: 'added' | 'removed' + ) => ( +
+ {items.map((item) => ( +
+ + {variant === 'removed' ? '- ' : '+ '} + {item.app} + + + ({item.environments.join(', ')}) + +
+ ))} +
+ ) + return ( +
+ {humanizeKey(key)} + {oldVal !== undefined && + Array.isArray(oldVal) && + renderEnvScopes(oldVal, 'removed')} + {newVal !== undefined && Array.isArray(newVal) && renderEnvScopes(newVal, 'added')} +
+ ) + } + + // Render account ID lists with avatars + if (isAccountIdList(key, oldVal) || isAccountIdList(key, newVal)) { + return ( +
+ {humanizeKey(key)} + {oldVal !== undefined && Array.isArray(oldVal) && ( + + )} + {newVal !== undefined && Array.isArray(newVal) && ( + + )} +
+ ) + } + + const isObj = typeof oldVal === 'object' || typeof newVal === 'object' + + if (isObj) { + return ( +
+ {humanizeKey(key)} + {oldVal !== undefined && ( +
+                    - {JSON.stringify(oldVal, null, 2)}
+                  
+ )} + {newVal !== undefined && ( +
+                    + {JSON.stringify(newVal, null, 2)}
+                  
+ )} +
+ ) + } + + return ( +
+ {humanizeKey(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} +
+ + + +
+
+ +
+ + {actorDisplayName} +
+
+ + +
+ {resourceMember ? ( + + ) : resourceSa ? ( + + ) : null} + {resourceDisplayLabel} + {!resourceMember && + !resourceSa && + 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 + } +} diff --git a/frontend/graphql/queries/organisation/getInvites.gql b/frontend/graphql/queries/organisation/getInvites.gql index 39b24132a..255dfd433 100644 --- a/frontend/graphql/queries/organisation/getInvites.gql +++ b/frontend/graphql/queries/organisation/getInvites.gql @@ -8,6 +8,10 @@ query GetInvites($orgId: ID!) { fullName self } + invitedByServiceAccount { + id + name + } inviteeEmail role { id diff --git a/frontend/graphql/queries/organisation/validateOrganisationInvite.gql b/frontend/graphql/queries/organisation/validateOrganisationInvite.gql index 9e67e195b..c5d38669b 100644 --- a/frontend/graphql/queries/organisation/validateOrganisationInvite.gql +++ b/frontend/graphql/queries/organisation/validateOrganisationInvite.gql @@ -10,6 +10,10 @@ query VerifyInvite($inviteId: ID!) { fullName email } + invitedByServiceAccount { + id + name + } apps { id name