diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 550f9c063..f657d3b27 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Install dependencies diff --git a/backend/api/auth.py b/backend/api/auth.py index 190d9308f..c14e863d3 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -164,24 +164,18 @@ def authenticate(self, request): raise exceptions.AuthenticationFailed( "Service account cannot access this environment" ) + except (exceptions.AuthenticationFailed, exceptions.NotFound): + raise # Let DRF exceptions propagate with their specific messages except Exception as ex: - # Distinguish between ServiceAccount not found and other potential errors + logger.debug(f"ServiceAccount authentication error: {ex}") + # Distinguish between ServiceAccount not found and other 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" - ) except ServiceAccount.DoesNotExist: raise exceptions.NotFound("Service account not found") - except ( - Exception - ) as ex: # Catch any other unexpected error during the re-check - logger.debug(f"Authentication error: {ex}") - raise exceptions.AuthenticationFailed( - f"Authentication error. Please check your authentication token or App / Environment access." - ) + raise exceptions.AuthenticationFailed( + "Authentication error. Please check your authentication token or App / Environment access." + ) return (user, auth) diff --git a/backend/api/authentication/adapters/social.py b/backend/api/authentication/adapters/social.py new file mode 100644 index 000000000..36234625a --- /dev/null +++ b/backend/api/authentication/adapters/social.py @@ -0,0 +1,67 @@ +import logging + +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount.models import SocialAccount +from django.contrib.auth import get_user_model + +logger = logging.getLogger(__name__) + +User = get_user_model() + + +class AutoLinkSocialAccountAdapter(DefaultSocialAccountAdapter): + """ + Custom SocialAccountAdapter that automatically links social logins + to existing users with matching email addresses. + + This is essential for SCIM-provisioned users: SCIM creates the + CustomUser + OrganisationMember but no SocialAccount. Without this + adapter, the first SSO login would fail with "User is already + registered with this e-mail address." + """ + + def pre_social_login(self, request, sociallogin): + # If the social account is already linked, nothing to do + if sociallogin.is_existing: + return + + email = sociallogin.user.email + if not email: + return + + # Defense-in-depth: never auto-link when the IdP explicitly + # marks the email unverified. Mirrors the SSO callback's gate. + extra_data = sociallogin.account.extra_data or {} + if extra_data.get("email_verified") is False: + logger.warning( + f"Refused auto-link: provider={sociallogin.account.provider} " + f"email={email} not verified by IdP." + ) + return + + try: + existing_user = User.objects.get(email=email) + except User.DoesNotExist: + return + + # Link the social account to the existing user + sociallogin.user = existing_user + social_account, created = SocialAccount.objects.get_or_create( + provider=sociallogin.account.provider, + uid=sociallogin.account.uid, + defaults={ + "user": existing_user, + "extra_data": sociallogin.account.extra_data, + }, + ) + + if not created: + social_account.extra_data = sociallogin.account.extra_data + social_account.save() + + sociallogin.account = social_account + + logger.info( + f"Auto-linked social account ({sociallogin.account.provider}) " + f"to existing user {email}" + ) diff --git a/backend/api/migrations/0123_add_team_models.py b/backend/api/migrations/0123_add_team_models.py new file mode 100644 index 000000000..792126da0 --- /dev/null +++ b/backend/api/migrations/0123_add_team_models.py @@ -0,0 +1,73 @@ +# Generated by Django 4.2.29 on 2026-03-30 11:38 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0122_sso_audit_fks_set_null'), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('description', models.TextField(blank=True, null=True)), + ('is_scim_managed', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_teams', to='api.organisationmember')), + ('member_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teams_as_member_role', to='api.role')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='api.organisation')), + ('service_account_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teams_as_sa_role', to='api.role')), + ], + ), + migrations.CreateModel( + name='TeamMembership', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('org_member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to='api.organisationmember')), + ('service_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to='api.serviceaccount')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='api.team')), + ], + ), + migrations.CreateModel( + name='TeamAppEnvironment', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.app')), + ('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.environment')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='app_environments', to='api.team')), + ], + ), + migrations.CreateModel( + name='EnvironmentKeyGrant', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('grant_type', models.CharField(choices=[('individual', 'Individual'), ('team', 'Team')], max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('environment_key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='api.environmentkey')), + ('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='key_grants', to='api.team')), + ], + ), + migrations.AddConstraint( + model_name='teammembership', + constraint=models.UniqueConstraint(condition=models.Q(('org_member__isnull', False)), fields=('team', 'org_member'), name='unique_team_user'), + ), + migrations.AddConstraint( + model_name='teammembership', + constraint=models.UniqueConstraint(condition=models.Q(('service_account__isnull', False)), fields=('team', 'service_account'), name='unique_team_sa'), + ), + migrations.AddConstraint( + model_name='teamappenvironment', + constraint=models.UniqueConstraint(fields=('team', 'environment'), name='unique_team_env'), + ), + ] diff --git a/backend/api/migrations/0124_backfill_environment_key_grants.py b/backend/api/migrations/0124_backfill_environment_key_grants.py new file mode 100644 index 000000000..39b35809c --- /dev/null +++ b/backend/api/migrations/0124_backfill_environment_key_grants.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.29 on 2026-03-30 11:38 + +from django.db import migrations + + +def backfill_grants(apps, schema_editor): + """ + Create an 'individual' EnvironmentKeyGrant for every existing EnvironmentKey. + This ensures the grant-tracking system works from day one — all pre-existing + keys are attributed to individual access. + """ + EnvironmentKey = apps.get_model("api", "EnvironmentKey") + EnvironmentKeyGrant = apps.get_model("api", "EnvironmentKeyGrant") + + grants_to_create = [] + for ek in EnvironmentKey.objects.filter(deleted_at__isnull=True).iterator(): + grants_to_create.append( + EnvironmentKeyGrant( + environment_key=ek, + grant_type="individual", + team=None, + ) + ) + if len(grants_to_create) >= 1000: + EnvironmentKeyGrant.objects.bulk_create(grants_to_create, ignore_conflicts=True) + grants_to_create = [] + + if grants_to_create: + EnvironmentKeyGrant.objects.bulk_create(grants_to_create, ignore_conflicts=True) + + +def reverse_backfill(apps, schema_editor): + """Remove all individual grants created by the forward migration.""" + EnvironmentKeyGrant = apps.get_model("api", "EnvironmentKeyGrant") + EnvironmentKeyGrant.objects.filter(grant_type="individual", team__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0123_add_team_models"), + ] + + operations = [ + migrations.RunPython(backfill_grants, reverse_backfill), + ] diff --git a/backend/api/migrations/0125_teams_and_scim.py b/backend/api/migrations/0125_teams_and_scim.py new file mode 100644 index 000000000..68fb9423f --- /dev/null +++ b/backend/api/migrations/0125_teams_and_scim.py @@ -0,0 +1,121 @@ +# Generated by Django 4.2.29 on 2026-04-14 15:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0124_backfill_environment_key_grants'), + ] + + operations = [ + migrations.AddField( + model_name='organisation', + name='scim_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='serviceaccount', + name='team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_service_accounts', to='api.team'), + ), + migrations.AddField( + model_name='team', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_teams', to='api.organisationmember'), + ), + migrations.CreateModel( + name='SCIMUser', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('external_id', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=254)), + ('display_name', models.CharField(blank=True, max_length=255)), + ('active', models.BooleanField(default=True)), + ('scim_data', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('org_member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.organisationmember')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scim_users', to='api.organisation')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SCIMToken', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('token_hash', models.CharField(db_index=True, max_length=128, unique=True)), + ('token_prefix', models.CharField(max_length=12)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.organisationmember')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scim_tokens', to='api.organisation')), + ], + ), + migrations.CreateModel( + name='SCIMGroup', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('external_id', models.CharField(max_length=255)), + ('display_name', models.CharField(max_length=255)), + ('scim_data', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scim_groups', to='api.organisation')), + ('team', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scim_group', to='api.team')), + ], + ), + migrations.CreateModel( + name='SCIMEvent', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('event_type', models.CharField(choices=[('user_created', 'User Created'), ('user_updated', 'User Updated'), ('user_deactivated', 'User Deactivated'), ('user_reactivated', 'User Reactivated'), ('group_created', 'Group Created'), ('group_updated', 'Group Updated'), ('group_deleted', 'Group Deleted'), ('member_added', 'Member Added to Group'), ('member_removed', 'Member Removed from Group')], max_length=32)), + ('status', models.CharField(choices=[('success', 'Success'), ('error', 'Error')], default='success', max_length=8)), + ('resource_type', models.CharField(choices=[('user', 'User'), ('group', 'Group')], max_length=16)), + ('resource_id', models.TextField(blank=True)), + ('resource_name', models.CharField(blank=True, max_length=255)), + ('detail', models.JSONField(default=dict)), + ('request_method', models.CharField(blank=True, max_length=8)), + ('request_path', models.TextField(blank=True)), + ('request_body', models.JSONField(blank=True, null=True)), + ('response_status', models.IntegerField(blank=True, null=True)), + ('response_body', models.JSONField(blank=True, null=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scim_events', to='api.organisation')), + ('scim_token', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='api.scimtoken')), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + migrations.AddConstraint( + model_name='scimuser', + constraint=models.UniqueConstraint(fields=('external_id', 'organisation'), name='unique_scim_user_external_id'), + ), + migrations.AddConstraint( + model_name='scimuser', + constraint=models.UniqueConstraint(fields=('email', 'organisation'), name='unique_scim_user_email'), + ), + migrations.AddConstraint( + model_name='scimgroup', + constraint=models.UniqueConstraint(fields=('external_id', 'organisation'), name='unique_scim_group_external_id'), + ), + migrations.AddIndex( + model_name='scimevent', + index=models.Index(fields=['organisation', '-timestamp'], name='scim_event_org_ts_idx'), + ), + migrations.AddIndex( + model_name='scimevent', + index=models.Index(fields=['scim_token', '-timestamp'], name='scim_event_token_ts_idx'), + ), + ] diff --git a/backend/api/migrations/0126_populate_team_owner.py b/backend/api/migrations/0126_populate_team_owner.py new file mode 100644 index 000000000..1b13669f4 --- /dev/null +++ b/backend/api/migrations/0126_populate_team_owner.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +def populate_team_owner(apps, schema_editor): + """Set owner = created_by for all existing teams.""" + Team = apps.get_model("api", "Team") + Team.objects.filter(owner__isnull=True, created_by__isnull=False).update( + owner=models.F("created_by") + ) + + +def reverse(apps, schema_editor): + """No-op reverse — owner data is additive.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0125_teams_and_scim'), + ] + + operations = [ + migrations.RunPython(populate_team_owner, reverse), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 771be6308..339980cf3 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -112,6 +112,7 @@ class Organisation(models.Model): stripe_subscription_id = models.CharField(max_length=255, blank=True, null=True) pricing_version = models.IntegerField(default=1) require_sso = models.BooleanField(default=False) + scim_enabled = models.BooleanField(default=False) list_display = ("name", "identity_key", "id") def save(self, *args, **kwargs): @@ -332,6 +333,13 @@ class ServiceAccount(models.Model): null=True, blank=True, ) + team = models.ForeignKey( + "Team", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="owned_service_accounts", + ) apps = models.ManyToManyField(App, related_name="service_accounts") identity_key = models.CharField(max_length=256, null=True, blank=True) server_wrapped_keyring = models.TextField(null=True) @@ -1060,6 +1068,273 @@ class PersonalSecret(models.Model): deleted_at = models.DateTimeField(blank=True, null=True) +class Team(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + name = models.CharField(max_length=64) + description = models.TextField(null=True, blank=True) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="teams" + ) + + # Optional roles — when set, override org role's app_permissions for team-accessed apps + member_role = models.ForeignKey( + Role, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="teams_as_member_role", + ) + service_account_role = models.ForeignKey( + Role, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="teams_as_sa_role", + ) + + is_scim_managed = models.BooleanField(default=False) + owner = models.ForeignKey( + OrganisationMember, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="owned_teams", + ) + created_by = models.ForeignKey( + OrganisationMember, + null=True, + on_delete=models.SET_NULL, + related_name="created_teams", + ) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + def delete(self, *args, **kwargs): + self.deleted_at = timezone.now() + self.save() + + def __str__(self): + return f"{self.name} ({self.organisation.name})" + + +class TeamMembership(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + team = models.ForeignKey( + Team, on_delete=models.CASCADE, related_name="memberships" + ) + org_member = models.ForeignKey( + OrganisationMember, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="team_memberships", + ) + service_account = models.ForeignKey( + ServiceAccount, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="team_memberships", + ) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["team", "org_member"], + condition=models.Q(org_member__isnull=False), + name="unique_team_user", + ), + models.UniqueConstraint( + fields=["team", "service_account"], + condition=models.Q(service_account__isnull=False), + name="unique_team_sa", + ), + ] + + +class TeamAppEnvironment(models.Model): + """Tracks which environments within an app a team has access to.""" + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + team = models.ForeignKey( + Team, on_delete=models.CASCADE, related_name="app_environments" + ) + app = models.ForeignKey(App, on_delete=models.CASCADE) + environment = models.ForeignKey(Environment, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["team", "environment"], + name="unique_team_env", + ) + ] + + +class EnvironmentKeyGrant(models.Model): + """Tracks why an EnvironmentKey exists — prevents accidental revocation + when removing team access.""" + + INDIVIDUAL = "individual" + TEAM = "team" + + GRANT_TYPE_CHOICES = [ + (INDIVIDUAL, "Individual"), + (TEAM, "Team"), + ] + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + environment_key = models.ForeignKey( + EnvironmentKey, on_delete=models.CASCADE, related_name="grants" + ) + grant_type = models.CharField(max_length=20, choices=GRANT_TYPE_CHOICES) + team = models.ForeignKey( + Team, on_delete=models.CASCADE, null=True, blank=True, related_name="key_grants" + ) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + + +class SCIMToken(models.Model): + """Bearer token for SCIM v2 provisioning API.""" + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="scim_tokens" + ) + name = models.CharField(max_length=64) + token_hash = models.CharField(max_length=128, unique=True, db_index=True) + token_prefix = models.CharField(max_length=12) + created_by = models.ForeignKey( + OrganisationMember, on_delete=models.SET_NULL, null=True + ) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + expires_at = models.DateTimeField(null=True, blank=True) + last_used_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + def delete(self, *args, **kwargs): + self.deleted_at = timezone.now() + self.save() + + +class SCIMUser(models.Model): + """Maps a SCIM external user ID to a Phase CustomUser + OrganisationMember.""" + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + external_id = models.CharField(max_length=255) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="scim_users" + ) + user = models.ForeignKey( + CustomUser, on_delete=models.CASCADE, null=True, blank=True + ) + org_member = models.ForeignKey( + OrganisationMember, on_delete=models.CASCADE, null=True, blank=True + ) + email = models.EmailField() + display_name = models.CharField(max_length=255, blank=True) + active = models.BooleanField(default=True) + scim_data = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["external_id", "organisation"], + name="unique_scim_user_external_id", + ), + models.UniqueConstraint( + fields=["email", "organisation"], + name="unique_scim_user_email", + ), + ] + + +class SCIMGroup(models.Model): + """Maps a SCIM external group ID to a Phase Team.""" + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + external_id = models.CharField(max_length=255) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="scim_groups" + ) + team = models.OneToOneField( + Team, on_delete=models.CASCADE, null=True, blank=True, related_name="scim_group" + ) + display_name = models.CharField(max_length=255) + scim_data = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["external_id", "organisation"], + name="unique_scim_group_external_id", + ), + ] + + +class SCIMEvent(models.Model): + """Audit log for SCIM provisioning operations.""" + + EVENT_TYPES = [ + ("user_created", "User Created"), + ("user_updated", "User Updated"), + ("user_deactivated", "User Deactivated"), + ("user_reactivated", "User Reactivated"), + ("group_created", "Group Created"), + ("group_updated", "Group Updated"), + ("group_deleted", "Group Deleted"), + ("member_added", "Member Added to Group"), + ("member_removed", "Member Removed from Group"), + ] + + RESOURCE_TYPES = [("user", "User"), ("group", "Group")] + + STATUS_CHOICES = [("success", "Success"), ("error", "Error")] + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="scim_events" + ) + scim_token = models.ForeignKey( + SCIMToken, on_delete=models.SET_NULL, null=True, related_name="events" + ) + event_type = models.CharField(max_length=32, choices=EVENT_TYPES) + status = models.CharField(max_length=8, choices=STATUS_CHOICES, default="success") + resource_type = models.CharField(max_length=16, choices=RESOURCE_TYPES) + resource_id = models.TextField(blank=True) + resource_name = models.CharField(max_length=255, blank=True) + detail = models.JSONField(default=dict) + request_method = models.CharField(max_length=8, blank=True) + request_path = models.TextField(blank=True) + request_body = models.JSONField(null=True, blank=True) + response_status = models.IntegerField(null=True, blank=True) + response_body = models.JSONField(null=True, blank=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index( + fields=["organisation", "-timestamp"], + name="scim_event_org_ts_idx", + ), + models.Index( + fields=["scim_token", "-timestamp"], + name="scim_event_token_ts_idx", + ), + ] + ordering = ["-timestamp"] + + class Lockbox(models.Model): id = models.TextField(default=uuid4, primary_key=True, editable=False) data = models.JSONField() diff --git a/backend/api/utils/access/permissions.py b/backend/api/utils/access/permissions.py index 4a08982a5..ba3de8e40 100644 --- a/backend/api/utils/access/permissions.py +++ b/backend/api/utils/access/permissions.py @@ -23,12 +23,23 @@ def user_is_org_member(user_id, org_id): def user_can_access_app(user_id, app_id): OrganisationMember = apps.get_model("api", "OrganisationMember") App = apps.get_model("api", "App") + TeamMembership = apps.get_model("api", "TeamMembership") app = App.objects.get(id=app_id) org_member = OrganisationMember.objects.get( user_id=user_id, organisation=app.organisation, deleted_at=None ) - return org_member in app.members.all() + + # Individual access + if org_member in app.members.all(): + return True + + # Team access + return TeamMembership.objects.filter( + org_member=org_member, + team__app_environments__app=app, + team__deleted_at__isnull=True, + ).exists() def user_can_access_environment(user_id, env_id): @@ -41,7 +52,7 @@ def user_can_access_environment(user_id, env_id): organisation=env.app.organisation, user_id=user_id, deleted_at=None ) return EnvironmentKey.objects.filter( - user_id=org_member, environment_id=env_id + user_id=org_member, environment_id=env_id, deleted_at__isnull=True ).exists() @@ -55,7 +66,29 @@ def service_account_can_access_environment(account_id, env_id): organisation=env.app.organisation, id=account_id, deleted_at=None ) return EnvironmentKey.objects.filter( - service_account=service_account, environment_id=env_id + service_account=service_account, + environment_id=env_id, + deleted_at__isnull=True, + ).exists() + + +def user_is_team_member(user_id, team_id): + """Check if a user is a member of a team, or has global access (Owner/Admin).""" + OrganisationMember = apps.get_model("api", "OrganisationMember") + Team = apps.get_model("api", "Team") + TeamMembership = apps.get_model("api", "TeamMembership") + + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org_member = OrganisationMember.objects.get( + user_id=user_id, organisation=team.organisation, deleted_at=None + ) + + if role_has_global_access(org_member.role): + return True + + return TeamMembership.objects.filter( + team=team, + org_member=org_member, ).exists() @@ -88,6 +121,54 @@ def role_has_permission(role, action, resource, is_app_resource=False): return action in resource_permissions +def _check_app_permission( + account, action, resource, app, is_service_account=False, is_app_resource=True +): + """Union check across individual + team access paths. `is_app_resource` + selects which permission map to look in (app_permissions vs the + top-level permissions map) — Apps/EncryptionMode/Members live at + the top level even when contextualised to a specific app.""" + Team = apps.get_model("api", "Team") + + if is_service_account: + has_individual = app.service_accounts.filter(id=account.id).exists() + org_role = account.role + role_field = "service_account_role" + membership_filter = {"memberships__service_account": account} + else: + has_individual = account in app.members.all() + org_role = account.role + role_field = "member_role" + membership_filter = {"memberships__org_member": account} + + if has_individual: + if role_has_permission(org_role, action, resource, is_app_resource): + return True + + teams = Team.objects.filter( + app_environments__app=app, + deleted_at__isnull=True, + **membership_filter, + ).distinct() + + for team in teams: + team_role = getattr(team, role_field) + # Team owners keep their org role on team-accessed apps. + is_team_owner = ( + not is_service_account + and team.owner_id is not None + and team.owner_id == account.id + ) + if team_role is None or is_team_owner: + if role_has_permission(org_role, action, resource, is_app_resource): + return True + else: + if role_has_permission(team_role, action, resource, is_app_resource): + return True + + return False + + def user_has_permission( account, action, @@ -95,6 +176,7 @@ def user_has_permission( organisation, is_app_resource=False, is_service_account=False, + app=None, ): OrganisationMember = apps.get_model("api", "OrganisationMember") @@ -108,7 +190,19 @@ def user_has_permission( user=account, organisation=organisation, deleted_at=None ) - return role_has_permission(org_member.role, action, resource, is_app_resource) + # No app context → org role only. + if app is None: + return role_has_permission( + org_member.role, action, resource, is_app_resource + ) + + # App-contextualised check — apply team override semantics. The + # `is_app_resource` flag still selects which map to look in for + # each effective role (Apps/EncryptionMode live at the top + # level even when scoped to a specific app). + return _check_app_permission( + org_member, action, resource, app, is_service_account, is_app_resource + ) except OrganisationMember.DoesNotExist: return False # User is not a member of the organization @@ -134,3 +228,57 @@ def role_has_global_access(role): except Role.DoesNotExist: return False # Role is not valid + + +def _check_sa_permission(user, service_account, action, resource): + """Permission check for service account operations. + + Org-level SAs use the standard org permission. Team-owned SAs + require team membership (or team-owner / global-access) AND the + resource permission on the effective role (team `member_role` + override → org role). Raises GraphQLError on denial. + """ + from graphql import GraphQLError + + OrganisationMember = apps.get_model("api", "OrganisationMember") + TeamMembership = apps.get_model("api", "TeamMembership") + + org = service_account.organisation + + if service_account.team is None: + if not user_has_permission(user, action, resource, org): + raise GraphQLError( + f"You don't have the permissions required to {action} " + f"{resource} in this organisation" + ) + return + + try: + org_member = OrganisationMember.objects.get( + user=user, organisation=org, deleted_at=None + ) + except OrganisationMember.DoesNotExist: + raise GraphQLError("You don't have access to this Service Account") + + if role_has_global_access(org_member.role): + return + + if ( + service_account.team.owner_id is not None + and service_account.team.owner_id == org_member.id + ): + return + + if not TeamMembership.objects.filter( + team=service_account.team, + org_member=org_member, + team__deleted_at__isnull=True, + ).exists(): + raise GraphQLError("You don't have access to this Service Account") + + effective_role = service_account.team.member_role or org_member.role + + if not role_has_permission(effective_role, action, resource): + raise GraphQLError( + f"You don't have the permissions required to {action} {resource}" + ) diff --git a/backend/api/utils/access/roles.py b/backend/api/utils/access/roles.py index b61f249aa..2ece4d9de 100644 --- a/backend/api/utils/access/roles.py +++ b/backend/api/utils/access/roles.py @@ -17,6 +17,8 @@ "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], "SSO": ["create", "read", "update", "delete"], + "Teams": ["create", "read", "update", "delete"], + "SCIM": ["create", "read", "update", "delete"], }, "app_permissions": { "Environments": ["create", "read", "update", "delete"], @@ -29,6 +31,7 @@ "ServiceAccounts": ["create", "read", "update", "delete"], "Integrations": ["create", "read", "update", "delete"], "EncryptionMode": ["read", "update"], + "Teams": ["create", "read", "update", "delete"], }, "global_access": True, }, @@ -50,6 +53,8 @@ "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], "SSO": ["create", "read", "update", "delete"], + "Teams": ["create", "read", "update", "delete"], + "SCIM": ["create", "read", "update", "delete"], }, "app_permissions": { "Environments": ["create", "read", "update", "delete"], @@ -62,6 +67,7 @@ "ServiceAccounts": ["create", "read", "update", "delete"], "Integrations": ["create", "read", "update", "delete"], "EncryptionMode": ["read", "update"], + "Teams": ["create", "read", "update", "delete"], }, "global_access": True, }, @@ -82,6 +88,8 @@ "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], "SSO": [], + "Teams": ["create", "read", "update", "delete"], + "SCIM": [], }, "app_permissions": { "Environments": ["read", "create", "update"], @@ -94,6 +102,7 @@ "ServiceAccounts": ["create", "read", "update", "delete"], "Integrations": ["create", "read", "update", "delete"], "EncryptionMode": ["read", "update"], + "Teams": ["create", "read", "update", "delete"], }, "global_access": False, }, @@ -118,6 +127,8 @@ ], "NetworkAccessPolicies": ["read"], "SSO": [], + "Teams": ["read"], + "SCIM": [], }, "app_permissions": { "Environments": ["read", "create", "update"], @@ -130,6 +141,7 @@ "ServiceAccounts": ["create"], "Integrations": ["create", "read", "update", "delete"], "EncryptionMode": ["read", "update"], + "Teams": ["read"], }, "global_access": False, }, @@ -150,6 +162,8 @@ "IntegrationCredentials": ["read"], "NetworkAccessPolicies": ["read"], "SSO": [], + "Teams": [], + "SCIM": [], }, "app_permissions": { "Environments": ["read", "create", "update", "delete"], @@ -162,6 +176,7 @@ "ServiceAccounts": ["read"], "Integrations": ["read"], "EncryptionMode": ["read"], + "Teams": ["read"], }, "global_access": False, }, diff --git a/backend/api/utils/keys.py b/backend/api/utils/keys.py new file mode 100644 index 000000000..cfed2b587 --- /dev/null +++ b/backend/api/utils/keys.py @@ -0,0 +1,189 @@ +""" +Server-side key wrapping utilities for team-based access. + +When a team is granted access to an SSE-enabled app, the server wraps +EnvironmentKeys for each team member using ServerEnvironmentKey data. +""" + +from django.apps import apps +from django.db import IntegrityError, transaction +from django.utils import timezone +from nacl.bindings import crypto_sign_ed25519_pk_to_curve25519 +from api.utils.crypto import ( + get_server_keypair, + decrypt_asymmetric, + encrypt_asymmetric, +) + + +def server_wrap_env_key_for_member(environment, identity_key): + """ + Unwraps a ServerEnvironmentKey and re-wraps the seed/salt for a user's + identity_key. Returns (wrapped_seed, wrapped_salt). + + Requires SSE to be enabled on the app (ServerEnvironmentKey must exist). + """ + ServerEnvironmentKey = apps.get_model("api", "ServerEnvironmentKey") + + server_env_key = ServerEnvironmentKey.objects.get( + environment=environment, deleted_at__isnull=True + ) + server_pk, server_sk = get_server_keypair() + + seed = decrypt_asymmetric( + server_env_key.wrapped_seed, server_sk.hex(), server_pk.hex() + ) + salt = decrypt_asymmetric( + server_env_key.wrapped_salt, server_sk.hex(), server_pk.hex() + ) + + # identity_key is Ed25519; encrypt_asymmetric needs Curve25519 + kx_pubkey_hex = crypto_sign_ed25519_pk_to_curve25519( + bytes.fromhex(identity_key) + ).hex() + + wrapped_seed = encrypt_asymmetric(seed, kx_pubkey_hex) + wrapped_salt = encrypt_asymmetric(salt, kx_pubkey_hex) + return wrapped_seed, wrapped_salt + + +def provision_team_environment_keys(team, app, members=None): + """ + Wraps EnvironmentKeys for team members for a given app's team environments. + + Called when: + - A team is granted access to an app (AddTeamApps) + - A member is added to a team that already has app access (AddTeamMembers) + - A SCIM-provisioned user completes their key ceremony (first login) + + Skips members without an identity_key (deferred until first login). + """ + Environment = apps.get_model("api", "Environment") + EnvironmentKey = apps.get_model("api", "EnvironmentKey") + EnvironmentKeyGrant = apps.get_model("api", "EnvironmentKeyGrant") + TeamAppEnvironment = apps.get_model("api", "TeamAppEnvironment") + + if not app.sse_enabled: + raise ValueError("Team-based access requires SSE-enabled apps.") + + env_ids = TeamAppEnvironment.objects.filter( + team=team, app=app + ).values_list("environment_id", flat=True) + environments = Environment.objects.filter(id__in=env_ids, deleted_at__isnull=True) + + if members is None: + members = team.memberships.all() + + for env in environments: + for membership in members: + account = membership.org_member or membership.service_account + if not account or not account.identity_key: + continue # Deferred until first login + + is_user = membership.org_member is not None + key_filter = { + "environment": env, + "user_id": membership.org_member_id if is_user else None, + "service_account_id": ( + membership.service_account_id if not is_user else None + ), + } + + env_key = EnvironmentKey.objects.filter( + deleted_at__isnull=True, **key_filter + ).first() + + if not env_key: + wrapped_seed, wrapped_salt = server_wrap_env_key_for_member( + env, account.identity_key + ) + # Nested atomic + IntegrityError recovery: a concurrent provisioning + # (e.g. simultaneous AddTeamApps and AddTeamMembers, or SCIM sync races) + # may insert the key between our filter and create. The unique + # constraint catches it — re-fetch instead of propagating the error. + try: + with transaction.atomic(): + # identity_key here is the env's, not the + # recipient's — clients derive the env keypair + # from it to decrypt secrets. + env_key = EnvironmentKey.objects.create( + **key_filter, + identity_key=env.identity_key, + wrapped_seed=wrapped_seed, + wrapped_salt=wrapped_salt, + ) + except IntegrityError: + env_key = EnvironmentKey.objects.get( + deleted_at__isnull=True, **key_filter + ) + + EnvironmentKeyGrant.objects.get_or_create( + environment_key=env_key, + grant_type="team", + team=team, + ) + + +def revoke_team_environment_keys(team, app=None, member=None, environments=None): + """ + Removes team-specific EnvironmentKeyGrants. + Soft-deletes EnvironmentKeys that have no remaining grants. + + Called when: + - A team is removed from an app (RemoveTeamApp) + - A member is removed from a team (RemoveTeamMember) + - A team is deleted (DeleteTeam) + - Specific environments are removed from a team-app link (UpdateTeamAppEnvironments) + """ + EnvironmentKey = apps.get_model("api", "EnvironmentKey") + EnvironmentKeyGrant = apps.get_model("api", "EnvironmentKeyGrant") + + grant_filter = {"grant_type": "team", "team": team} + if app: + grant_filter["environment_key__environment__app"] = app + if environments is not None: + grant_filter["environment_key__environment__in"] = environments + if member: + if hasattr(member, "user"): + grant_filter["environment_key__user_id"] = member.id + else: + grant_filter["environment_key__service_account_id"] = member.id + + grants = EnvironmentKeyGrant.objects.filter(**grant_filter) + env_key_ids = list(grants.values_list("environment_key_id", flat=True)) + grants.delete() + + # Soft-delete orphaned EnvironmentKeys (no remaining grants) + for ek_id in env_key_ids: + if not EnvironmentKeyGrant.objects.filter(environment_key_id=ek_id).exists(): + EnvironmentKey.objects.filter(id=ek_id).update( + deleted_at=timezone.now() + ) + + +def provision_pending_team_keys(org_member): + """ + Called after a user completes their key ceremony (first login). + Wraps EnvironmentKeys for all team-accessible SSE environments + that the user didn't yet have keys for. + """ + App = apps.get_model("api", "App") + TeamMembership = apps.get_model("api", "TeamMembership") + TeamAppEnvironment = apps.get_model("api", "TeamAppEnvironment") + + team_memberships = TeamMembership.objects.filter( + org_member=org_member, + team__deleted_at__isnull=True, + ).select_related("team") + + for tm in team_memberships: + team_app_ids = ( + TeamAppEnvironment.objects.filter(team=tm.team) + .values_list("app_id", flat=True) + .distinct() + ) + + for app_id in team_app_ids: + app = App.objects.get(id=app_id) + if app.sse_enabled: + provision_team_environment_keys(tm.team, app, members=[tm]) diff --git a/backend/api/views/secrets.py b/backend/api/views/secrets.py index 4f44773bf..b9498d0b8 100644 --- a/backend/api/views/secrets.py +++ b/backend/api/views/secrets.py @@ -90,6 +90,7 @@ def initial(self, request, *args, **kwargs): organisation, True, request.auth.get("service_account") is not None, + app=env.app, ): raise PermissionDenied( f"You don't have permission to {action} secrets in this environment." @@ -97,10 +98,7 @@ def initial(self, request, *args, **kwargs): def get(self, request, *args, **kwargs): - env_id = request.headers["environment"] - env = Environment.objects.get(id=env_id) - if not env.id: - return JsonResponse({"error": "Environment doesn't exist"}, status=404) + env = request.auth["environment"] ip_address, user_agent = get_resolver_request_meta(request) @@ -280,17 +278,14 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): - env_id = request.headers["environment"] - env = Environment.objects.get(id=env_id) - if not env: - return JsonResponse({"error": "Environment doesn't exist"}, status=404) + env = request.auth["environment"] request_body = json.loads(request.body) ip_address, user_agent = get_resolver_request_meta(request) if check_for_duplicates_blind( - request_body["secrets"], request.headers["environment"] + request_body["secrets"], env.id ): return JsonResponse({"error": "Duplicate secret found"}, status=409) @@ -358,17 +353,14 @@ def post(self, request, *args, **kwargs): def put(self, request, *args, **kwargs): - env_id = request.headers["environment"] - env = Environment.objects.get(id=env_id) - if not env: - return JsonResponse({"error": "Environment doesn't exist"}, status=404) + env = request.auth["environment"] request_body = json.loads(request.body) ip_address, user_agent = get_resolver_request_meta(request) if check_for_duplicates_blind( - request_body["secrets"], request.headers["environment"] + request_body["secrets"], env.id ): return JsonResponse({"error": "Duplicate secret found"}, status=409) @@ -538,6 +530,7 @@ def initial(self, request, *args, **kwargs): organisation, True, request.auth.get("service_account") is not None, + app=env.app, ): raise PermissionDenied( f"You don't have permission to {action} secrets in this environment." diff --git a/backend/api/views/sso.py b/backend/api/views/sso.py index a246f771b..07609fa84 100644 --- a/backend/api/views/sso.py +++ b/backend/api/views/sso.py @@ -483,7 +483,33 @@ def _complete_login_bypassing_allauth( already_linked = SocialAccount.objects.filter( user=existing_user, provider=provider ).exists() - if not already_linked: + if already_linked: + # Same provider linked under a different uid — refuse + # the silent re-link so the user can clean up + # deliberately. + logger.warning( + f"Refused SSO link: provider={provider} email={email} " + f"already linked under a different uid." + ) + raise ValueError( + "This sign-in identity does not match the one on " + "file for this account. Contact your administrator." + ) + + # SCIM-provisioned members get an auto-link exception: + # the admin already vouched for IdP→email when issuing the + # SCIM token. Only org-level callbacks have this context. + from api.models import SCIMUser + + scim_authorised = bool( + org_config_id + and SCIMUser.objects.filter( + user=existing_user, + organisation=org, + active=True, + ).exists() + ) + if not scim_authorised: logger.warning( f"Refused silent SSO link: provider={provider} " f"email={email} already has an account." @@ -493,41 +519,57 @@ def _complete_login_bypassing_allauth( "Sign in with your existing method, then link " "this provider from your account settings." ) - # Same provider linked under a different uid — refuse the - # silent re-link so the user can clean up deliberately. - logger.warning( - f"Refused SSO link: provider={provider} email={email} " - f"already linked under a different uid." + + # Belt-and-braces: SCIM trust doesn't override an explicit + # `email_verified=false` from the IdP this round-trip. + if extra_data.get("email_verified") is False: + logger.warning( + f"Refused SCIM auto-link: provider={provider} " + f"email={email} not verified by IdP." + ) + raise ValueError( + "Email not verified by identity provider. " + "Contact your administrator." + ) + + logger.info( + f"Auto-linked SSO identity for SCIM-provisioned " + f"user: provider={provider} email={email} " + f"org={org.name}" ) - raise ValueError( - "This sign-in identity does not match the one on " - "file for this account. Contact your administrator." + user = existing_user + sa = SocialAccount.objects.create( + provider=provider, + uid=uid, + user=user, + extra_data=extra_data, ) + else: + from api.views.auth_password import username_for_email - from api.views.auth_password import username_for_email - - user = User.objects.create_user( - username=username_for_email(email), - email=email, - password=None, - ) - sa = SocialAccount.objects.create( - provider=provider, - uid=uid, - user=user, - extra_data=extra_data, - ) + user = User.objects.create_user( + username=username_for_email(email), + email=email, + password=None, + ) + sa = SocialAccount.objects.create( + provider=provider, + uid=uid, + user=user, + extra_data=extra_data, + ) - # Fire allauth's signup signal so receivers (e.g. Slack notifier) run - # for org-SSO signups. The instance-level OAuth/OIDC flow goes - # through allauth and gets this for free; this callback creates - # users manually and would otherwise skip every receiver. - try: - from allauth.account.signals import user_signed_up + # Fire allauth's signup signal so receivers (e.g. Slack + # notifier) run for org-SSO signups. The instance-level + # OAuth/OIDC flow goes through allauth and gets this for + # free; this callback creates users manually and would + # otherwise skip every receiver. + try: + from allauth.account.signals import user_signed_up - user_signed_up.send(sender=user.__class__, request=request, user=user) - except Exception: - logger.exception("Failed to dispatch user_signed_up signal for %s", email) + user_signed_up.send(sender=user.__class__, request=request, user=user) + except Exception: + logger.exception("Failed to dispatch user_signed_up signal for %s", email) # Save the SocialToken if we have one if token and token.token: diff --git a/backend/backend/graphene/middleware.py b/backend/backend/graphene/middleware.py index efc9207cb..d98a1f6fb 100644 --- a/backend/backend/graphene/middleware.py +++ b/backend/backend/graphene/middleware.py @@ -123,8 +123,8 @@ def resolve(self, next, root, info: GraphQLResolveInfo, **kwargs): if not org_id: return next(root, info, **kwargs) - # Skip the require_sso lookup when the session is already SSO-bound - # to this org — it would have been blocked at sign-in otherwise. + # Skip enforcement when the session is already SSO-bound to this + # org — it would have been blocked at sign-in otherwise. session = getattr(request, "session", None) session_method = session.get("auth_method") if session else None session_org_id = session.get("auth_sso_org_id") if session else None @@ -135,8 +135,11 @@ def resolve(self, next, root, info: GraphQLResolveInfo, **kwargs): if decision is None: return next(root, info, **kwargs) - blocked, org_name = decision - if not blocked: + require_sso, org_name = decision + + # Block on require_sso OR per-member SCIM (IdP is the source of + # truth for SCIM-provisioned access to this org). + if not (require_sso or self._is_scim_managed(request, user, org_id)): return next(root, info, **kwargs) raise SSORequiredError(org_name, str(org_id)) @@ -184,6 +187,31 @@ def _get_org_decision(cls, request, org_id): cache_l1[key] = decision return decision + @classmethod + def _is_scim_managed(cls, request, user, org_id): + """Whether `user` is a SCIM-managed member of `org_id`. + Cached per-(user, org) within the request scope.""" + cache_attr = "_scim_managed_cache" + request_cache = getattr(request, cache_attr, None) + if request_cache is None: + request_cache = {} + setattr(request, cache_attr, request_cache) + + user_id = getattr(user, "userId", None) or getattr(user, "id", None) or user + key = (str(user_id), str(org_id)) + if key in request_cache: + return request_cache[key] + + result = OrganisationMember.objects.filter( + organisation_id=org_id, + user=user, + deleted_at__isnull=True, + scimuser__isnull=False, + scimuser__active=True, + ).exists() + request_cache[key] = result + return result + @classmethod def _resolve_org_id(cls, request, kwargs, info=None): request_cache = getattr(request, cls._ID_CACHE_ATTR, None) diff --git a/backend/backend/graphene/mutations/access.py b/backend/backend/graphene/mutations/access.py index cbb59dc3d..bb55e22ab 100644 --- a/backend/backend/graphene/mutations/access.py +++ b/backend/backend/graphene/mutations/access.py @@ -4,8 +4,9 @@ OrganisationMember, Role, ServiceAccount, + TeamMembership, ) -from api.utils.access.permissions import user_has_permission +from api.utils.access.permissions import user_has_permission, role_has_global_access from backend.graphene.types import NetworkAccessPolicyType, RoleType, IdentityType from api.models import Identity from django.utils import timezone @@ -502,6 +503,12 @@ 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_member = OrganisationMember.objects.get( + user=info.context.user, + organisation_id=organisation_id, + deleted_at=None, + ) + for account_input in account_inputs: account_filter = { "organisation_id": organisation_id, @@ -514,6 +521,21 @@ def mutate(cls, root, info, account_inputs, organisation_id): else ServiceAccount.objects.get(**account_filter) ) + # For team-owned SAs, verify team membership + if ( + account_input.account_type != AccountTypeEnum.USER + and account.team is not None + and not role_has_global_access(org_member.role) + and not TeamMembership.objects.filter( + team=account.team, + org_member=org_member, + team__deleted_at__isnull=True, + ).exists() + ): + raise GraphQLError( + "You don't have access to this Service Account" + ) + account.network_policies.set( NetworkAccessPolicy.objects.filter(id__in=account_input.policy_ids) ) diff --git a/backend/backend/graphene/mutations/app.py b/backend/backend/graphene/mutations/app.py index 3b259951c..583b6d80d 100644 --- a/backend/backend/graphene/mutations/app.py +++ b/backend/backend/graphene/mutations/app.py @@ -10,6 +10,7 @@ from api.models import ( App, EnvironmentKey, + EnvironmentKeyGrant, Organisation, OrganisationMember, Role, @@ -17,11 +18,53 @@ ) from backend.graphene.types import AppType, MemberType from django.conf import settings +from django.db import transaction from django.db.models import Q +from django.utils import timezone CLOUD_HOSTED = settings.APP_HOST == "cloud" +def _upsert_active_env_key(condition, defaults): + """Like `update_or_create` but scoped to active rows only — the + unique constraint allows soft-deleted dupes to coexist with one + active row, which would trip MultipleObjectsReturned otherwise.""" + try: + env_key = EnvironmentKey.objects.get(deleted_at__isnull=True, **condition) + for k, v in defaults.items(): + setattr(env_key, k, v) + env_key.save() + return env_key + except EnvironmentKey.DoesNotExist: + return EnvironmentKey.objects.create(**condition, **defaults) + + +def _revoke_individual_keys_for_app(app, user_id=None, service_account_id=None): + """Drop individual grants on `app`'s envs for the given member; + soft-delete only keys with no grants left so team access survives.""" + key_filter = {"environment__app": app, "deleted_at__isnull": True} + if user_id is not None: + key_filter["user_id"] = user_id + if service_account_id is not None: + key_filter["service_account_id"] = service_account_id + + keys = list(EnvironmentKey.objects.filter(**key_filter)) + key_ids = [k.id for k in keys] + + EnvironmentKeyGrant.objects.filter( + environment_key_id__in=key_ids, grant_type="individual" + ).delete() + + keys_with_remaining = set( + EnvironmentKeyGrant.objects.filter( + environment_key_id__in=key_ids + ).values_list("environment_key_id", flat=True) + ) + EnvironmentKey.objects.filter( + id__in=[kid for kid in key_ids if kid not in keys_with_remaining] + ).update(deleted_at=timezone.now()) + + class CreateAppMutation(graphene.Mutation): class Arguments: id = graphene.ID(required=True) @@ -242,33 +285,44 @@ def mutate(cls, root, info, app_id, members): member = ServiceAccount.objects.get(id=member_id, deleted_at=None) if not user_has_permission( - user, "create", permission_key, app.organisation, True + user, "create", permission_key, app.organisation, True, app=app ): raise GraphQLError( f"You don't have permission to add {member_type.lower()}s to this App" ) - if member_type == MemberType.USER: - app.members.add(member) - else: - app.service_accounts.add(member) - - for key in env_keys: - defaults = { - "wrapped_seed": key.wrapped_seed, - "wrapped_salt": key.wrapped_salt, - "identity_key": key.identity_key, - } - - condition = { - "environment_id": key.env_id, - "user_id": key.user_id if member_type == MemberType.USER else None, - "service_account_id": ( - key.user_id if member_type == MemberType.SERVICE else None - ), - } - - EnvironmentKey.objects.update_or_create(**condition, defaults=defaults) + # Atomic so a mid-loop failure can't leave the M2M row + # without env keys — that combo would short-circuit + # _check_app_permission past any team role override. + with transaction.atomic(): + if member_type == MemberType.USER: + app.members.add(member) + else: + app.service_accounts.add(member) + + for key in env_keys: + defaults = { + "wrapped_seed": key.wrapped_seed, + "wrapped_salt": key.wrapped_salt, + "identity_key": key.identity_key, + } + + condition = { + "environment_id": key.env_id, + "user_id": key.user_id if member_type == MemberType.USER else None, + "service_account_id": ( + key.user_id if member_type == MemberType.SERVICE else None + ), + } + + env_key = _upsert_active_env_key(condition, defaults) + # Track the grant so removing an unrelated team grant + # later doesn't orphan-delete this key. + EnvironmentKeyGrant.objects.get_or_create( + environment_key=env_key, + grant_type="individual", + team=None, + ) return BulkAddAppMembersMutation(app=app) @@ -297,35 +351,45 @@ def mutate( member = ServiceAccount.objects.get(id=member_id, deleted_at=None) if not user_has_permission( - info.context.user, "create", permission_key, app.organisation, True + info.context.user, "create", permission_key, app.organisation, True, app=app ): raise GraphQLError("You don't have permission to add members to this App") if not user_can_access_app(user.userId, app.id): raise GraphQLError("You don't have access to this app") - if member_type == MemberType.USER: - app.members.add(member) - else: - app.service_accounts.add(member) - - # Create new env keys - for key in env_keys: - defaults = { - "wrapped_seed": key.wrapped_seed, - "wrapped_salt": key.wrapped_salt, - "identity_key": key.identity_key, - } - - condition = { - "environment_id": key.env_id, - "user_id": key.user_id if member_type == MemberType.USER else None, - "service_account_id": ( - key.user_id if member_type == MemberType.SERVICE else None - ), - } - - EnvironmentKey.objects.update_or_create(**condition, defaults=defaults) + # Atomic so a mid-loop failure can't leave the M2M row + # without env keys — that combo would short-circuit + # _check_app_permission past any team role override. + with transaction.atomic(): + if member_type == MemberType.USER: + app.members.add(member) + else: + app.service_accounts.add(member) + + for key in env_keys: + defaults = { + "wrapped_seed": key.wrapped_seed, + "wrapped_salt": key.wrapped_salt, + "identity_key": key.identity_key, + } + + condition = { + "environment_id": key.env_id, + "user_id": key.user_id if member_type == MemberType.USER else None, + "service_account_id": ( + key.user_id if member_type == MemberType.SERVICE else None + ), + } + + env_key = _upsert_active_env_key(condition, defaults) + # Track the grant so removing an unrelated team grant + # later doesn't orphan-delete this key. + EnvironmentKeyGrant.objects.get_or_create( + environment_key=env_key, + grant_type="individual", + team=None, + ) return AddAppMemberMutation(app=app) @@ -349,7 +413,7 @@ def mutate(cls, root, info, member_id, app_id, member_type=MemberType.USER): permission_key = "ServiceAccounts" if not user_has_permission( - info.context.user, "delete", permission_key, app.organisation, True + info.context.user, "delete", permission_key, app.organisation, True, app=app ): raise GraphQLError( f"You don't have permission to remove {permission_key} from this App" @@ -372,17 +436,13 @@ def mutate(cls, root, info, member_id, app_id, member_type=MemberType.USER): raise GraphQLError("This user is not a member of this app") app.members.remove(member) - EnvironmentKey.objects.filter( - environment__app=app, user_id=member_id - ).delete() + _revoke_individual_keys_for_app(app, user_id=member_id) elif member_type == MemberType.SERVICE: if member not in app.service_accounts.all(): raise GraphQLError("This service account is not a member of this app") app.service_accounts.remove(member) - EnvironmentKey.objects.filter( - environment__app=app, service_account_id=member_id - ).delete() + _revoke_individual_keys_for_app(app, service_account_id=member_id) return RemoveAppMemberMutation(app=app) diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index c01af8da3..627f26cda 100644 --- a/backend/backend/graphene/mutations/environment.py +++ b/backend/backend/graphene/mutations/environment.py @@ -19,6 +19,7 @@ App, Environment, EnvironmentKey, + EnvironmentKeyGrant, EnvironmentSync, EnvironmentToken, Organisation, @@ -111,7 +112,7 @@ def mutate( app = App.objects.get(id=environment_data.app_id) if not user_has_permission( - info.context.user, "create", "Environments", app.organisation, True + info.context.user, "create", "Environments", app.organisation, True, app=app ): raise GraphQLError( "You don't have permission to create environments in this organisation" @@ -164,23 +165,31 @@ def mutate( ) # Add the org owner to the environment - EnvironmentKey.objects.create( + owner_key = EnvironmentKey.objects.create( environment=environment, user=org_owner, identity_key=environment_data.identity_key, wrapped_seed=environment_data.wrapped_seed, wrapped_salt=environment_data.wrapped_salt, ) + EnvironmentKeyGrant.objects.create( + environment_key=owner_key, + grant_type="individual", + ) # Add admins to the environment for key in admin_keys: - EnvironmentKey.objects.create( + admin_key = EnvironmentKey.objects.create( environment=environment, user_id=key.user_id, wrapped_seed=key.wrapped_seed, wrapped_salt=key.wrapped_salt, identity_key=key.identity_key, ) + EnvironmentKeyGrant.objects.create( + environment_key=admin_key, + grant_type="individual", + ) # Add Server keys if provided if wrapped_seed and wrapped_salt: @@ -208,7 +217,7 @@ def mutate(cls, root, info, environment_id, name): org = environment.app.organisation if not user_has_permission( - info.context.user, "update", "Environments", org, True + info.context.user, "update", "Environments", org, True, app=environment.app ): raise GraphQLError("You do not have permission to rename environments") @@ -250,7 +259,7 @@ def mutate(cls, root, info, environment_id): org = environment.app.organisation if not user_has_permission( - info.context.user, "delete", "Environments", org, True + info.context.user, "delete", "Environments", org, True, app=environment.app ): raise GraphQLError("You do not have permission to delete environments") @@ -277,7 +286,7 @@ def mutate(cls, root, info, app_id, environment_order): app = App.objects.get(id=app_id) org = app.organisation - if not user_has_permission(user, "update", "Environments", org, True): + if not user_has_permission(user, "update", "Environments", org, True, app=app): raise GraphQLError("You do not have permission to update environments") if not can_use_custom_envs(org): @@ -351,6 +360,10 @@ def mutate( wrapped_seed=wrapped_seed, wrapped_salt=wrapped_salt, ) + EnvironmentKeyGrant.objects.create( + environment_key=environment_key, + grant_type="individual", + ) return CreateEnvironmentKeyMutation(environment_key=environment_key) @@ -377,7 +390,7 @@ def mutate( permission_key = "ServiceAccounts" if not user_has_permission( - info.context.user, "update", permission_key, app.organisation, True + info.context.user, "update", permission_key, app.organisation, True, app=app ): raise GraphQLError("You don't have permission to update App member access") @@ -403,13 +416,50 @@ def mutate( ) with transaction.atomic(): - # delete all existing keys for this member - EnvironmentKey.objects.filter(**key_to_delete_filter).delete() + # Drop only individual grants; team grants on the same key + # must survive this edit. Soft-delete keys with no grants + # left, and re-attach individual grants to keys preserved + # for their team grant (avoids the (env, user|sa) unique + # conflict that would arise from creating a duplicate row). + old_keys = list( + EnvironmentKey.objects.filter( + deleted_at__isnull=True, **key_to_delete_filter + ) + ) + old_key_ids = [k.id for k in old_keys] + EnvironmentKeyGrant.objects.filter( + environment_key_id__in=old_key_ids, grant_type="individual" + ).delete() + + keys_with_remaining_grants = set( + EnvironmentKeyGrant.objects.filter( + environment_key_id__in=old_key_ids + ).values_list("environment_key_id", flat=True) + ) + EnvironmentKey.objects.filter( + id__in=[ + kid for kid in old_key_ids + if kid not in keys_with_remaining_grants + ] + ).update(deleted_at=timezone.now()) + + preserved_by_env = { + k.environment_id: k + for k in old_keys + if k.id in keys_with_remaining_grants + } - # set new keys for key in env_keys: - - EnvironmentKey.objects.create( + preserved = preserved_by_env.get(key.env_id) + if preserved is not None: + EnvironmentKeyGrant.objects.get_or_create( + environment_key=preserved, + grant_type="individual", + team=None, + ) + continue + + new_key = EnvironmentKey.objects.create( environment_id=key.env_id, user_id=key.user_id if member_type == MemberType.USER else None, service_account_id=( @@ -419,6 +469,11 @@ def mutate( wrapped_salt=key.wrapped_salt, identity_key=key.identity_key, ) + EnvironmentKeyGrant.objects.create( + environment_key=new_key, + grant_type="individual", + team=None, + ) return UpdateMemberEnvScopeMutation(app=app) @@ -546,7 +601,7 @@ def mutate( app = App.objects.get(id=app_id) if not user_has_permission( - info.context.user, "create", "Tokens", app.organisation, True + info.context.user, "create", "Tokens", app.organisation, True, app=app ): raise GraphQLError("You don't have permission to create Tokens in this App") @@ -600,7 +655,7 @@ def mutate(cls, root, info, token_id): token = ServiceToken.objects.get(id=token_id) org = token.app.organisation - if not user_has_permission(info.context.user, "delete", "Tokens", org, True): + if not user_has_permission(info.context.user, "delete", "Tokens", org, True, app=token.app): raise GraphQLError("You don't have permission to delete Tokens in this App") token.deleted_at = timezone.now() @@ -621,9 +676,11 @@ class Arguments: def mutate(cls, root, info, env_id, name, path): user = info.context.user - org = Environment.objects.get(id=env_id).app.organisation + env_obj = Environment.objects.get(id=env_id) + app = env_obj.app + org = app.organisation - if not user_has_permission(info.context.user, "create", "Secrets", org, True): + if not user_has_permission(info.context.user, "create", "Secrets", org, True, app=app): raise GraphQLError( "You don't have permission to create folders in this organisation" ) @@ -668,6 +725,7 @@ def mutate(cls, root, info, folder_id): "Secrets", folder.environment.app.organisation, True, + app=folder.environment.app, ): raise GraphQLError( "You don't have permission to delete folders in this organisation" @@ -715,7 +773,7 @@ def mutate(cls, root, info, secret_data): env = Environment.objects.get(id=secret_data.env_id) org = env.app.organisation - if not user_has_permission(info.context.user, "create", "Secrets", org, True): + if not user_has_permission(info.context.user, "create", "Secrets", org, True, app=env.app): raise GraphQLError( "You don't have permission to create secrets in this organisation" ) @@ -784,7 +842,7 @@ def mutate(cls, root, info, secrets_data): org = env.app.organisation if not user_has_permission( - info.context.user, "create", "Secrets", org, True + info.context.user, "create", "Secrets", org, True, app=env.app ): raise GraphQLError( "You don't have permission to create secrets in this organisation" @@ -851,7 +909,7 @@ def mutate(cls, root, info, id, secret_data): env = secret.environment org = env.app.organisation - if not user_has_permission(info.context.user, "update", "Secrets", org, True): + if not user_has_permission(info.context.user, "update", "Secrets", org, True, app=env.app): raise GraphQLError( "You don't have permission to update secrets in this organisation" ) @@ -924,7 +982,7 @@ def mutate(cls, root, info, secrets_data): org = env.app.organisation if not user_has_permission( - info.context.user, "create", "Secrets", org, True + info.context.user, "create", "Secrets", org, True, app=env.app ): raise GraphQLError( "You don't have permission to update secrets in this organisation" @@ -999,7 +1057,7 @@ def mutate(cls, root, info, id): env = secret.environment org = env.app.organisation - if not user_has_permission(info.context.user, "delete", "Secrets", org, True): + if not user_has_permission(info.context.user, "delete", "Secrets", org, True, app=env.app): raise GraphQLError( "You don't have permission to delete secrets in this organisation" ) @@ -1037,7 +1095,7 @@ def mutate(cls, root, info, ids): org = env.app.organisation if not user_has_permission( - info.context.user, "delete", "Secrets", org, True + info.context.user, "delete", "Secrets", org, True, app=env.app ): raise GraphQLError( "You don't have permission to delete secrets in this organisation" diff --git a/backend/backend/graphene/mutations/organisation.py b/backend/backend/graphene/mutations/organisation.py index e282ba288..1893773c9 100644 --- a/backend/backend/graphene/mutations/organisation.py +++ b/backend/backend/graphene/mutations/organisation.py @@ -6,7 +6,11 @@ user_is_org_member, ) from api.utils.access.roles import default_roles +from api.utils.keys import provision_pending_team_keys from api.tasks.emails import send_invite_email_job +import logging + +logger = logging.getLogger(__name__) from backend.quotas import can_add_account import graphene from django.db import transaction @@ -92,18 +96,10 @@ def mutate( class UpdateUserWrappedSecretsMutation(graphene.Mutation): - """Re-wrap this member's keyring after the caller proves they hold the - recovery mnemonic. Used by SSO recovery (where there's no login - password to verify against, so identity is proven via the mnemonic - alone). - - Requires identity_key matching the member's stored identity_key — - proves the caller derived the keyring from the same mnemonic they - registered with. Without this, an authenticated session could - overwrite the wrapped_keyring with a foreign identity and lock the - member out of the org permanently. Every Phase user has their own - independently-derived keyring, so this check must be against the - member's identity_key, not the org's (which is the owner's). + """Re-wrap this member's keyring (SSO recovery) or establish it on + first-key ceremony (SCIM-preprovisioned members). Validates the + supplied identity_key against the member's stored one, except on + first ceremony when there's nothing yet to compare against. """ class Arguments: @@ -128,35 +124,38 @@ def mutate(cls, root, info, org_id, identity_key, wrapped_keyring, wrapped_recov except OrganisationMember.DoesNotExist: raise GraphQLError("Not a member of this organisation.") - if org_member.identity_key != identity_key: + if org_member.identity_key and org_member.identity_key != identity_key: raise GraphQLError("Invalid recovery proof.") + first_key_ceremony = identity_key and not org_member.identity_key + + if identity_key: + org_member.identity_key = identity_key + org_member.wrapped_keyring = wrapped_keyring org_member.wrapped_recovery = wrapped_recovery org_member.save() + # SCIM users get their team env keys on first ceremony. + if first_key_ceremony: + try: + provision_pending_team_keys(org_member) + except Exception as e: + # Log but don't fail — keys can be re-provisioned later. + logger.error( + f"Failed to provision pending team keys for {org_member.id}: {e}", + exc_info=True, + ) + return UpdateUserWrappedSecretsMutation(org_member=org_member) class RecoverAccountKeyringMutation(graphene.Mutation): """Rewrap this member's keyring with a deviceKey derived from the - user's account password. Used by the recovery flow when the local - keyring has been lost (cleared cache, new device) but the user still - remembers their password. - - Two server-side proofs are required: - 1. identity_key matches the member's stored identity_key — proves - the caller derived the keyring from the same mnemonic they - registered with. Every Phase user has their own keyring, so - this is checked against the member's identity_key, not the - org's (which is the owner's). - 2. auth_hash matches user.password — proves the password the user - is wrapping the keyring with is also their account login auth. - - The mutation does NOT change user.password. The auth_hash check is a - guardrail to keep auth and wrap passwords unified; if it fails, the - user is trying to wrap the keyring with a password that doesn't - authenticate them, which we never persist. + user's account password. Used when the local keyring is lost + (cleared cache, new device) but the user still has their password. + Requires identity_key to match the member's stored value AND + auth_hash to match user.password. Does not rotate user.password. """ class Arguments: @@ -231,27 +230,8 @@ def mutate( class ChangeAccountPasswordMutation(graphene.Mutation): - """Rotate the user's account password and rewrap the active org's - keyring with the new deviceKey. Used by the in-session change-password - dialog where the user supplies their current password, a new password, - and the org's recovery mnemonic. - - Three server-side proofs are required: - 1. current_auth_hash matches user.password — proves the caller - knows the current login password. - 2. identity_key matches the member's stored identity_key — proves - the caller derived the keyring from the same mnemonic they - registered with. Every Phase user has their own keyring, so - this is checked against the member's identity_key, not the - org's (which is the owner's). - 3. user is a member of the org. - - On success: user.password is set to new_auth_hash, the org's - wrapped_keyring + wrapped_recovery are replaced, and the session is - refreshed so the post-rotation HASH_SESSION_KEY stays valid. - - Only the active org's keyring is rewrapped. Other orgs the user - belongs to remain encrypted with the old deviceKey; they'll fall + """Rotate user.password and rewrap the active org's keyring with + the new deviceKey. Other orgs keep the old deviceKey and fall through to per-org recovery on next access. """ @@ -550,6 +530,12 @@ def mutate(cls, root, info, member_id): if org_member.user == info.context.user: raise GraphQLError("You can't remove yourself from an organisation") + if org_member.scimuser_set.exists(): + raise GraphQLError( + "SCIM-managed members cannot be removed from the console. " + "Deprovision them from your identity provider." + ) + org_member.delete() if settings.APP_HOST == "cloud": @@ -598,6 +584,22 @@ 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") + # Members who haven't completed their key ceremony (e.g. + # SCIM-provisioned users pre-first-login) can only hold roles + # that won't make them a service account handler. Otherwise + # the next SA creation tries to wrap a keyring for an empty + # identity_key and breaks. Mirrors the invite safelist. + if not org_member.identity_key and ( + role_has_global_access(new_role) + or role_has_permission(new_role, "create", "ServiceAccountTokens") + ): + raise GraphQLError( + "This member hasn't completed account setup yet — " + "wait until they sign in for the first time before " + "assigning a role with global access or service " + "account token permissions." + ) + org_member.role = new_role org_member.save() diff --git a/backend/backend/graphene/mutations/service_accounts.py b/backend/backend/graphene/mutations/service_accounts.py index cc91d8ba3..909b4c9fa 100644 --- a/backend/backend/graphene/mutations/service_accounts.py +++ b/backend/backend/graphene/mutations/service_accounts.py @@ -2,16 +2,23 @@ from django.db import transaction from graphql import GraphQLError from api.models import ( + App, Organisation, OrganisationMember, Role, ServiceAccount, ServiceAccountHandler, ServiceAccountToken, + Team, + TeamAppEnvironment, + TeamMembership, Identity, ) +from api.utils.keys import provision_team_environment_keys from api.utils.access.permissions import ( + _check_sa_permission, role_has_global_access, + role_has_permission, user_has_permission, user_is_org_member, ) @@ -36,6 +43,7 @@ class Arguments: identity_key = graphene.String() server_wrapped_keyring = graphene.String(required=False) server_wrapped_recovery = graphene.String(required=False) + team_id = graphene.ID(required=False) service_account = graphene.Field(ServiceAccountType) @@ -51,15 +59,43 @@ def mutate( identity_key, server_wrapped_keyring=None, server_wrapped_recovery=None, + team_id=None, ): user = info.context.user org = Organisation.objects.get(id=organisation_id) - if not user_has_permission(user, "create", "ServiceAccounts", org): - raise GraphQLError( - "You don't have the permissions required to create Service Accounts in this organisation" + # Permission check: team-owned SAs use team member_role override + team = None + if team_id: + team = Team.objects.get(id=team_id, organisation=org, deleted_at__isnull=True) + org_member = OrganisationMember.objects.get( + user=user, organisation=org, deleted_at=None ) + if not role_has_global_access(org_member.role): + is_team_owner = team.owner_id is not None and team.owner_id == org_member.id + + if not is_team_owner: + # Must be a team member + if not TeamMembership.objects.filter( + team=team, org_member=org_member + ).exists(): + raise GraphQLError( + "You must be a member of the team to create a team-owned Service Account" + ) + + # Check effective role: team member_role if set, else org role + effective_role = team.member_role or org_member.role + if not role_has_permission(effective_role, "create", "ServiceAccounts"): + raise GraphQLError( + "You don't have the permissions required to create Service Accounts" + ) + else: + if not user_has_permission(user, "create", "ServiceAccounts", org): + raise GraphQLError( + "You don't have the permissions required to create Service Accounts in this organisation" + ) + if handlers is None or len(handlers) == 0: raise GraphQLError("At least one service account handler must be provided") @@ -78,6 +114,7 @@ def mutate( identity_key=identity_key, server_wrapped_keyring=server_wrapped_keyring, server_wrapped_recovery=server_wrapped_recovery, + team=team, ) for handler in handlers: @@ -88,6 +125,25 @@ def mutate( wrapped_recovery=handler.wrapped_recovery, ) + # Auto-add team-owned SA as a member of the team + if team: + membership, _ = TeamMembership.objects.get_or_create( + team=team, service_account=service_account + ) + + # Provision environment keys for the SA on all team apps + app_ids = ( + TeamAppEnvironment.objects.filter(team=team) + .values_list("app_id", flat=True) + .distinct() + ) + for app_id in app_ids: + app = App.objects.get(id=app_id) + if app.sse_enabled: + provision_team_environment_keys( + team, app, members=[membership] + ) + if settings.APP_HOST == "cloud": from ee.billing.stripe import update_stripe_subscription_seats @@ -116,12 +172,7 @@ def mutate( user = info.context.user service_account = ServiceAccount.objects.get(id=service_account_id) - if not user_has_permission( - user, "update", "ServiceAccounts", service_account.organisation - ): - raise GraphQLError( - "You don't have the permissions required to update Service Accounts in this organisation" - ) + _check_sa_permission(user, service_account, "update", "ServiceAccounts") service_account.server_wrapped_keyring = server_wrapped_keyring service_account.server_wrapped_recovery = server_wrapped_recovery @@ -143,11 +194,12 @@ def mutate(cls, root, info, service_account_id): user = info.context.user service_account = ServiceAccount.objects.get(id=service_account_id) - if not user_has_permission( - user, "update", "ServiceAccounts", service_account.organisation - ): + _check_sa_permission(user, service_account, "update", "ServiceAccounts") + + # Team-owned SAs must always use server-side KMS + if service_account.team is not None: raise GraphQLError( - "You don't have the permissions required to update Service Accounts in this organisation" + "Team-owned service accounts require server-side key management and cannot be switched to client-side." ) # Delete server-wrapped keys to disable server-side key management @@ -174,12 +226,7 @@ def mutate(cls, root, info, service_account_id, name, role_id, identity_ids=None user = info.context.user service_account = ServiceAccount.objects.get(id=service_account_id) - if not user_has_permission( - user, "update", "ServiceAccounts", service_account.organisation - ): - raise GraphQLError( - "You don't have the permissions required to update Service Accounts in this organisation" - ) + _check_sa_permission(user, service_account, "update", "ServiceAccounts") role = Role.objects.get(id=role_id, organisation=service_account.organisation) @@ -218,10 +265,34 @@ def mutate(cls, root, info, organisation_id, handlers): "You are not a member of this organisation and cannot perform this operation" ) - ServiceAccountHandler.objects.filter(service_account__organisation=org).delete() + if not user_has_permission(user, "update", "ServiceAccounts", org): + raise GraphQLError( + "You don't have permission to manage service accounts" + ) + + # Pre-flight: org-level perms aren't sufficient for team-owned + # SAs — fail before the bulk delete below if any are off-limits. + sa_ids = set(h.service_account_id for h in handlers) + target_sas = ServiceAccount.objects.filter( + id__in=sa_ids, + organisation=org, + deleted_at__isnull=True, + ).select_related("team") + for sa in target_sas: + _check_sa_permission(user, sa, "update", "ServiceAccounts") + + # Scope the delete to listed SAs so we don't wipe handlers for + # team-owned SAs the caller can't see. + ServiceAccountHandler.objects.filter( + service_account__organisation=org, + service_account_id__in=sa_ids, + service_account__deleted_at__isnull=True, + ).delete() for handler in handlers: - service_account = ServiceAccount.objects.get(id=handler.service_account_id) + service_account = ServiceAccount.objects.get( + id=handler.service_account_id, deleted_at__isnull=True + ) if not ServiceAccountHandler.objects.filter( service_account=service_account, user_id=handler.member_id @@ -247,12 +318,7 @@ def mutate(cls, root, info, service_account_id): user = info.context.user service_account = ServiceAccount.objects.get(id=service_account_id) - if not user_has_permission( - user, "delete", "ServiceAccounts", service_account.organisation - ): - raise GraphQLError( - "You don't have the permissions required to delete Service Accounts in this organisation" - ) + _check_sa_permission(user, service_account, "delete", "ServiceAccounts") service_account.delete() @@ -293,12 +359,7 @@ def mutate( user=user, organisation=service_account.organisation, deleted_at=None ) - if not user_has_permission( - user, "create", "ServiceAccountTokens", service_account.organisation - ): - raise GraphQLError( - "You don't have the permissions required to create Service Tokens in this organisation" - ) + _check_sa_permission(user, service_account, "create", "ServiceAccountTokens") if expiry is not None: expires_at = datetime.fromtimestamp(expiry / 1000) @@ -329,13 +390,88 @@ def mutate(cls, root, info, token_id): user = info.context.user token = ServiceAccountToken.objects.get(id=token_id) - if not user_has_permission( - user, "delete", "ServiceAccountTokens", token.service_account.organisation - ): - raise GraphQLError( - "You don't have the permissions required to delete Service Tokens in this organisation" - ) + _check_sa_permission(user, token.service_account, "delete", "ServiceAccountTokens") token.delete() return DeleteServiceAccountTokenMutation(ok=True) + + +class CreateServerSideServiceAccountTokenMutation(graphene.Mutation): + """Create a service account token using server-side key management. + + The server decrypts the SA keyring, generates a token with key splitting, + and returns the full token string. Requires SSK to be enabled on the SA. + This allows team members who are not SA handlers to create tokens. + """ + + class Arguments: + service_account_id = graphene.ID(required=True) + name = graphene.String(required=True) + expiry = graphene.BigInt(required=False) + + token_string = graphene.String() + token = graphene.Field(ServiceAccountTokenType) + + @classmethod + def mutate(cls, root, info, service_account_id, name, expiry=None): + import json + from api.utils.crypto import ( + get_server_keypair, + decrypt_asymmetric, + split_secret_hex, + wrap_share_hex, + random_hex, + ed25519_to_kx, + ) + + user = info.context.user + service_account = ServiceAccount.objects.get(id=service_account_id) + org_member = OrganisationMember.objects.get( + user=user, organisation=service_account.organisation, deleted_at=None + ) + + _check_sa_permission(user, service_account, "create", "ServiceAccountTokens") + + if not service_account.server_wrapped_keyring: + raise GraphQLError( + "Server-side key management must be enabled to create tokens this way" + ) + + # Decrypt SA keyring using server keypair + 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, kx_priv = ed25519_to_kx(keyring["publicKey"], keyring["privateKey"]) + + # Generate token material + 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) + + if expiry is not None: + expires_at = datetime.fromtimestamp(expiry / 1000) + else: + expires_at = None + + token = ServiceAccountToken.objects.create( + service_account=service_account, + name=name, + identity_key=kx_pub, + token=token_value, + wrapped_key_share=wrapped_share_b, + created_by=org_member, + expires_at=expires_at, + ) + + full_token = f"pss_service:v2:{token_value}:{kx_pub}:{share_a}:{wrap_key}" + + return CreateServerSideServiceAccountTokenMutation( + token_string=full_token, + token=token, + ) + + diff --git a/backend/backend/graphene/mutations/teams.py b/backend/backend/graphene/mutations/teams.py new file mode 100644 index 000000000..57a9b7fd7 --- /dev/null +++ b/backend/backend/graphene/mutations/teams.py @@ -0,0 +1,554 @@ +import graphene +from graphql import GraphQLError +from django.db import transaction +from django.utils import timezone +from api.models import ( + App, + Environment, + Organisation, + OrganisationMember, + Role, + ServiceAccount, + ServiceAccountToken, + Team, + TeamAppEnvironment, + TeamMembership, +) +from api.utils.access.permissions import ( + role_has_global_access, + user_can_access_app, + user_has_permission, + user_is_org_member, + user_is_team_member, +) +from api.utils.keys import ( + provision_team_environment_keys, + revoke_team_environment_keys, +) +from backend.quotas import can_use_teams +from backend.graphene.types import TeamType, MemberType + + +def _get_org_member(user, org): + """Get the requesting user's OrganisationMember.""" + return OrganisationMember.objects.get(user=user, organisation=org, deleted_at=None) + + +def _check_team_membership(user, team): + """Verify the user is a member of the team or has global access (Owner/Admin).""" + if not user_is_team_member(user.userId, team.id): + raise GraphQLError("You don't have access to this team") + + +def _is_team_owner(user, team): + """Check if the user is the owner of the team.""" + if team.owner is None: + return False + return team.owner.user_id == user.userId + + +class CreateTeamMutation(graphene.Mutation): + class Arguments: + organisation_id = graphene.ID(required=True) + name = graphene.String(required=True) + description = graphene.String(required=False) + member_role_id = graphene.ID(required=False) + service_account_role_id = graphene.ID(required=False) + + team = graphene.Field(TeamType) + + @classmethod + @transaction.atomic + def mutate( + cls, + root, + info, + organisation_id, + name, + description=None, + member_role_id=None, + service_account_role_id=None, + ): + user = info.context.user + org = Organisation.objects.get(id=organisation_id) + + if not user_is_org_member(user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "create", "Teams", org): + raise GraphQLError("You don't have permission to create Teams") + + if not can_use_teams(org): + raise GraphQLError( + "Teams require a Pro or Enterprise plan. Please upgrade to use this feature." + ) + + if not name or not name.strip(): + raise GraphQLError("Team name cannot be blank") + if len(name) > 64: + raise GraphQLError("Team name cannot exceed 64 characters") + + member_role = None + if member_role_id: + member_role = Role.objects.get(id=member_role_id, organisation=org) + + sa_role = None + if service_account_role_id: + sa_role = Role.objects.get(id=service_account_role_id, organisation=org) + + org_member = _get_org_member(user, org) + + team = Team.objects.create( + name=name.strip(), + description=description, + organisation=org, + member_role=member_role, + service_account_role=sa_role, + owner=org_member, + created_by=org_member, + ) + + # Auto-add the creator as a team member + TeamMembership.objects.create(team=team, org_member=org_member) + + return CreateTeamMutation(team=team) + + +class UpdateTeamMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + name = graphene.String(required=False) + description = graphene.String(required=False) + member_role_id = graphene.ID(required=False) + service_account_role_id = graphene.ID(required=False) + + team = graphene.Field(TeamType) + + @classmethod + def mutate( + cls, + root, + info, + team_id, + name=None, + description=None, + member_role_id=None, + service_account_role_id=None, + ): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "update", "Teams", org): + raise GraphQLError("You don't have permission to update Teams") + + _check_team_membership(user, team) + + if team.is_scim_managed and (name is not None or description is not None): + raise GraphQLError( + "Name and description of SCIM-managed teams cannot be changed from the console" + ) + + if name is not None: + if not name or not name.strip(): + raise GraphQLError("Team name cannot be blank") + if len(name) > 64: + raise GraphQLError("Team name cannot exceed 64 characters") + team.name = name.strip() + + if description is not None: + team.description = description + + if member_role_id is not None: + if member_role_id == "": + team.member_role = None + else: + team.member_role = Role.objects.get(id=member_role_id, organisation=org) + + if service_account_role_id is not None: + if service_account_role_id == "": + team.service_account_role = None + else: + team.service_account_role = Role.objects.get( + id=service_account_role_id, organisation=org + ) + + team.save() + return UpdateTeamMutation(team=team) + + +class TransferTeamOwnershipMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + new_owner_id = graphene.ID(required=True) + + team = graphene.Field(TeamType) + + @classmethod + def mutate(cls, root, info, team_id, new_owner_id): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + org_member = _get_org_member(user, org) + + # Only the current team owner or global access users can transfer ownership + is_owner = team.owner_id is not None and team.owner_id == org_member.id + if not is_owner and not role_has_global_access(org_member.role): + raise GraphQLError( + "Only the team owner or organisation admins can transfer team ownership" + ) + + # Validate the new owner is a team member + new_owner = OrganisationMember.objects.get( + id=new_owner_id, organisation=org, deleted_at=None + ) + if not TeamMembership.objects.filter(team=team, org_member=new_owner).exists(): + raise GraphQLError("The new owner must be a member of the team") + + team.owner = new_owner + team.save() + return TransferTeamOwnershipMutation(team=team) + + +class DeleteTeamMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + @transaction.atomic + def mutate(cls, root, info, team_id): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "delete", "Teams", org): + raise GraphQLError("You don't have permission to delete Teams") + + if team.is_scim_managed: + raise GraphQLError( + "This team is managed by SCIM and cannot be manually deleted" + ) + + _check_team_membership(user, team) + + # Revoke all team environment key grants + revoke_team_environment_keys(team) + + # Soft-delete team-owned service accounts and their tokens + now = timezone.now() + for sa in ServiceAccount.objects.filter(team=team, deleted_at__isnull=True): + sa.deleted_at = now + sa.save() + ServiceAccountToken.objects.filter( + service_account=sa, deleted_at__isnull=True + ).update(deleted_at=now) + + team.deleted_at = timezone.now() + team.save() + + return DeleteTeamMutation(ok=True) + + +class AddTeamMembersMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + member_ids = graphene.List(graphene.NonNull(graphene.ID), required=True) + member_type = MemberType(required=False, default_value=MemberType.USER) + + team = graphene.Field(TeamType) + + @classmethod + @transaction.atomic + def mutate(cls, root, info, team_id, member_ids, member_type=MemberType.USER): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "update", "Teams", org): + raise GraphQLError("You don't have permission to manage Teams") + + _check_team_membership(user, team) + + if team.is_scim_managed and member_type == MemberType.USER: + raise GraphQLError( + "This team is managed by SCIM. Members cannot be manually added." + ) + + new_memberships = [] + for mid in member_ids: + if member_type == MemberType.USER: + member = OrganisationMember.objects.get( + id=mid, organisation=org, deleted_at=None + ) + if TeamMembership.objects.filter(team=team, org_member=member).exists(): + continue + tm = TeamMembership.objects.create(team=team, org_member=member) + else: + sa = ServiceAccount.objects.get( + id=mid, organisation=org, deleted_at=None + ) + if TeamMembership.objects.filter( + team=team, service_account=sa + ).exists(): + continue + tm = TeamMembership.objects.create(team=team, service_account=sa) + new_memberships.append(tm) + + # Provision environment keys for new members on all team apps + if new_memberships: + app_ids = ( + TeamAppEnvironment.objects.filter(team=team) + .values_list("app_id", flat=True) + .distinct() + ) + for app_id in app_ids: + app = App.objects.get(id=app_id) + if app.sse_enabled: + provision_team_environment_keys(team, app, members=new_memberships) + + return AddTeamMembersMutation(team=team) + + +class RemoveTeamMemberMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + member_id = graphene.ID(required=True) + member_type = MemberType(required=False, default_value=MemberType.USER) + + team = graphene.Field(TeamType) + + @classmethod + @transaction.atomic + def mutate(cls, root, info, team_id, member_id, member_type=MemberType.USER): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "update", "Teams", org): + raise GraphQLError("You don't have permission to manage Teams") + + _check_team_membership(user, team) + + if team.is_scim_managed and member_type == MemberType.USER: + raise GraphQLError( + "This team is managed by SCIM. Members cannot be manually removed." + ) + + if member_type == MemberType.USER: + member = OrganisationMember.objects.get(id=member_id, deleted_at=None) + membership = TeamMembership.objects.get(team=team, org_member=member) + revoke_team_environment_keys(team, member=member) + else: + member = ServiceAccount.objects.get(id=member_id, deleted_at=None) + # Block removing a team-owned SA from its owning team + if member.team_id == team.id: + raise GraphQLError( + "This service account is owned by this team and cannot be removed." + ) + membership = TeamMembership.objects.get(team=team, service_account=member) + revoke_team_environment_keys(team, member=member) + + membership.delete() + + return RemoveTeamMemberMutation(team=team) + + +class AppEnvironmentInput(graphene.InputObjectType): + app_id = graphene.ID(required=True) + env_ids = graphene.List(graphene.NonNull(graphene.ID), required=True) + + +class AddTeamAppsMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + app_envs = graphene.List(graphene.NonNull(AppEnvironmentInput), required=True) + + team = graphene.Field(TeamType) + + @classmethod + @transaction.atomic + def mutate(cls, root, info, team_id, app_envs): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "update", "Teams", org): + raise GraphQLError("You don't have permission to manage Teams") + + _check_team_membership(user, team) + + for app_env in app_envs: + app = App.objects.get(id=app_env.app_id, organisation=org) + + # Check app-level Teams permission (team creators always have access) + if not _is_team_owner(user, team) and not user_has_permission( + user, "create", "Teams", org, is_app_resource=True, app=app + ): + raise GraphQLError( + f"You don't have permission to add teams to app '{app.name}'" + ) + + # Actor must have access to the app + if not user_can_access_app(user.userId, app.id): + raise GraphQLError(f"You don't have access to app '{app.name}'") + + # Team access requires SSE + if not app.sse_enabled: + raise GraphQLError( + f"App '{app.name}' does not have server-side encryption enabled. " + "Team-based access requires SSE." + ) + + for env_id in app_env.env_ids: + env = Environment.objects.get(id=env_id, app=app) + TeamAppEnvironment.objects.get_or_create( + team=team, app=app, environment=env + ) + + # Provision keys for all team members + provision_team_environment_keys(team, app) + + return AddTeamAppsMutation(team=team) + + +class RemoveTeamAppMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + app_id = graphene.ID(required=True) + + team = graphene.Field(TeamType) + + @classmethod + @transaction.atomic + def mutate(cls, root, info, team_id, app_id): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "update", "Teams", org): + raise GraphQLError("You don't have permission to manage Teams") + + _check_team_membership(user, team) + + app = App.objects.get(id=app_id, organisation=org) + + # Check app-level Teams permission (team creators always have access) + if not _is_team_owner(user, team) and not user_has_permission( + user, "delete", "Teams", org, is_app_resource=True, app=app + ): + raise GraphQLError( + "You don't have permission to remove teams from this app" + ) + + # Revoke grants before removing the app-env links + revoke_team_environment_keys(team, app=app) + + TeamAppEnvironment.objects.filter(team=team, app=app).delete() + + return RemoveTeamAppMutation(team=team) + + +class UpdateTeamAppEnvironmentsMutation(graphene.Mutation): + class Arguments: + team_id = graphene.ID(required=True) + app_id = graphene.ID(required=True) + env_ids = graphene.List(graphene.NonNull(graphene.ID), required=True) + + team = graphene.Field(TeamType) + + @classmethod + @transaction.atomic + def mutate(cls, root, info, team_id, app_id, env_ids): + user = info.context.user + team = Team.objects.get(id=team_id, deleted_at__isnull=True) + org = team.organisation + + if not user_is_org_member(user.userId, org.id): + raise GraphQLError("You don't have access to this organisation") + + if not user_has_permission(user, "update", "Teams", org): + raise GraphQLError("You don't have permission to manage Teams") + + _check_team_membership(user, team) + + app = App.objects.get(id=app_id, organisation=org) + + # Check app-level Teams permission (team creators always have access) + if not _is_team_owner(user, team) and not user_has_permission( + user, "update", "Teams", org, is_app_resource=True, app=app + ): + raise GraphQLError( + "You don't have permission to manage team environments for this app" + ) + + if not user_can_access_app(user.userId, app.id): + raise GraphQLError("You don't have access to this app") + + if not app.sse_enabled: + raise GraphQLError("Team-based access requires server-side encryption.") + + # Determine which environments to add/remove + current_env_ids = set( + TeamAppEnvironment.objects.filter(team=team, app=app).values_list( + "environment_id", flat=True + ) + ) + new_env_ids = set(env_ids) + + # Validate all new env_ids belong to this app + valid_env_ids = set( + Environment.objects.filter(id__in=new_env_ids, app=app).values_list( + "id", flat=True + ) + ) + invalid = new_env_ids - valid_env_ids + if invalid: + raise GraphQLError("Some environment IDs do not belong to this app") + + to_remove = current_env_ids - new_env_ids + to_add = new_env_ids - current_env_ids + + # Remove environments no longer in scope + if to_remove: + envs_to_remove = Environment.objects.filter(id__in=to_remove) + revoke_team_environment_keys(team, app=app, environments=envs_to_remove) + TeamAppEnvironment.objects.filter( + team=team, app=app, environment_id__in=to_remove + ).delete() + + # Add new environments + for env_id in to_add: + env = Environment.objects.get(id=env_id, app=app) + TeamAppEnvironment.objects.get_or_create( + team=team, app=app, environment=env + ) + + # Provision keys for newly added environments + if to_add: + provision_team_environment_keys(team, app) + + return UpdateTeamAppEnvironmentsMutation(team=team) diff --git a/backend/backend/graphene/queries/service_accounts.py b/backend/backend/graphene/queries/service_accounts.py index b6f16bdf8..f5ea3b050 100644 --- a/backend/backend/graphene/queries/service_accounts.py +++ b/backend/backend/graphene/queries/service_accounts.py @@ -1,9 +1,10 @@ from api.utils.access.permissions import ( + role_has_global_access, user_can_access_app, user_has_permission, user_is_org_member, ) -from api.models import App, Organisation, OrganisationMember, Role, ServiceAccount +from api.models import App, Organisation, OrganisationMember, Role, ServiceAccount, TeamMembership from .access import resolve_organisation_global_access_users from django.db.models import Q from graphql import GraphQLError @@ -11,14 +12,63 @@ def resolve_service_accounts(root, info, org_id, service_account_id=None): org = Organisation.objects.get(id=org_id) - if user_has_permission(info.context.user.userId, "read", "ServiceAccounts", org): - filter = {"organisation": org, "deleted_at": None} + has_org_permission = user_has_permission(info.context.user.userId, "read", "ServiceAccounts", org) - if service_account_id is not None: - filter["id"] = service_account_id + # Team-based access: if requesting a specific SA, allow access if user shares a team with it + has_team_access = False + if not has_org_permission and service_account_id is not None: + try: + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None + ) + user_team_ids = TeamMembership.objects.filter( + org_member=org_member, + team__deleted_at__isnull=True, + ).values_list("team_id", flat=True) + has_team_access = ServiceAccount.objects.filter( + id=service_account_id, + deleted_at=None, + team_id__in=user_team_ids, + ).exists() or TeamMembership.objects.filter( + service_account_id=service_account_id, + team_id__in=user_team_ids, + ).exists() + except OrganisationMember.DoesNotExist: + pass - return ServiceAccount.objects.filter(**filter) + if not (has_org_permission or has_team_access): + # No org-level perm and no team-based access — empty queryset + # (not None) so the GraphQL field returns [] rather than null. + return ServiceAccount.objects.none() + + filter = {"organisation": org, "deleted_at": None} + + if service_account_id is not None: + filter["id"] = service_account_id + + qs = ServiceAccount.objects.filter(**filter) + + # Non-global-access users: scope to org-level SAs + SAs in their teams + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None + ) + if not role_has_global_access(org_member.role): + user_team_ids = TeamMembership.objects.filter( + org_member=org_member, + team__deleted_at__isnull=True, + ).values_list("team_id", flat=True) + if has_org_permission: + # Org permission: see org-level + team-scoped SAs in user's teams + qs = qs.filter(Q(team__isnull=True) | Q(team_id__in=user_team_ids)) + else: + # Team access only: only see SAs that are in shared teams + qs = qs.filter( + Q(team_id__in=user_team_ids) | + Q(team_memberships__team_id__in=user_team_ids) + ).distinct() + + return qs def resolve_service_account_handlers(root, info, org_id): diff --git a/backend/backend/graphene/queries/syncing.py b/backend/backend/graphene/queries/syncing.py index 00d06ca6b..5bb5fc7a7 100644 --- a/backend/backend/graphene/queries/syncing.py +++ b/backend/backend/graphene/queries/syncing.py @@ -363,9 +363,10 @@ def resolve_syncs(root, info, app_id=None, env_id=None, org_id=None): # If both app_id and env_id are provided if app_id and env_id: - org = App.objects.get(id=app_id).organisation + app = App.objects.get(id=app_id) + org = app.organisation if not user_has_permission( - info.context.user, "read", "Integrations", org, True + info.context.user, "read", "Integrations", org, True, app=app ): return [] @@ -380,9 +381,10 @@ def resolve_syncs(root, info, app_id=None, env_id=None, org_id=None): # If only app_id is provided elif app_id: - org = App.objects.get(id=app_id).organisation + app = App.objects.get(id=app_id) + org = app.organisation if not user_has_permission( - info.context.user, "read", "Integrations", org, True + info.context.user, "read", "Integrations", org, True, app=app ): return [] @@ -395,9 +397,10 @@ def resolve_syncs(root, info, app_id=None, env_id=None, org_id=None): # If only env_id is provided elif env_id: - org = Environment.objects.get(id=env_id).app.organisation + env = Environment.objects.get(id=env_id) + org = env.app.organisation if not user_has_permission( - info.context.user, "read", "Integrations", org, True + info.context.user, "read", "Integrations", org, True, app=env.app ): return [] diff --git a/backend/backend/graphene/queries/teams.py b/backend/backend/graphene/queries/teams.py new file mode 100644 index 000000000..b88d207e4 --- /dev/null +++ b/backend/backend/graphene/queries/teams.py @@ -0,0 +1,30 @@ +from graphql import GraphQLError +from api.models import Team, OrganisationMember +from api.utils.access.permissions import ( + user_has_permission, + user_is_org_member, +) + + +def resolve_teams(root, info, organisation_id, team_id=None): + """Return teams visible to the requesting user.""" + user = info.context.user + + if not user_is_org_member(user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") + + org_member = OrganisationMember.objects.get( + user_id=user.userId, organisation_id=organisation_id, deleted_at=None + ) + + if not user_has_permission(user, "read", "Teams", org_member.organisation): + return [] + + qs = Team.objects.filter( + organisation_id=organisation_id, deleted_at__isnull=True + ).order_by("name") + + if team_id: + qs = qs.filter(id=team_id) + + return qs diff --git a/backend/backend/graphene/types.py b/backend/backend/graphene/types.py index ef4580601..4f81c29d2 100644 --- a/backend/backend/graphene/types.py +++ b/backend/backend/graphene/types.py @@ -1,6 +1,7 @@ from api.services import Providers, ServiceConfig from api.utils.syncing.auth import get_credentials from api.utils.access.permissions import ( + user_can_access_app, user_can_access_environment, user_has_permission, ) @@ -17,6 +18,7 @@ DynamicSecret, Environment, EnvironmentKey, + EnvironmentKeyGrant, EnvironmentSync, EnvironmentSyncEvent, EnvironmentToken, @@ -40,6 +42,11 @@ ServiceToken, UserToken, Identity, + Team, + TeamMembership, + TeamAppEnvironment, + SCIMToken, + SCIMEvent, ) from logs.dynamodb_models import KMSLog from django.utils import timezone @@ -124,6 +131,7 @@ def resolve_updated_by(self, info): class OrganisationType(DjangoObjectType): role = graphene.Field(RoleType) member_id = graphene.ID() + member_scim_managed = graphene.Boolean() keyring = graphene.String() recovery = graphene.String() plan_detail = graphene.Field(OrganisationPlanType) @@ -143,6 +151,7 @@ class Meta: "recovery", "pricing_version", "require_sso", + "scim_enabled", ) def resolve_sso_providers(self, info): @@ -164,6 +173,9 @@ def resolve_role(self, info): def resolve_member_id(self, info): return OrganisationType._get_member(self, info).id + def resolve_member_scim_managed(self, info): + return OrganisationType._get_member(self, info).scimuser_set.exists() + def resolve_keyring(self, info): return OrganisationType._get_member(self, info).wrapped_keyring @@ -210,6 +222,7 @@ class OrganisationMemberType(DjangoObjectType): role = graphene.Field(RoleType) self = graphene.Boolean() last_login = graphene.DateTime() + scim_managed = graphene.Boolean() app_memberships = graphene.List(graphene.NonNull(lambda: AppMembershipType)) tokens = graphene.List(graphene.NonNull(lambda: UserTokenType)) network_policies = graphene.List(graphene.NonNull(lambda: NetworkAccessPolicyType)) @@ -243,6 +256,10 @@ def resolve_full_name(self, info): return name if self.user.full_name: return self.user.full_name + # Fall back to SCIM display name + scim_user = self.scimuser_set.first() + if scim_user and scim_user.display_name: + return scim_user.display_name return None def resolve_avatar_url(self, info): @@ -259,6 +276,9 @@ def resolve_self(self, info): def resolve_last_login(self, info): return self.user.last_login + def resolve_scim_managed(self, info): + return self.scimuser_set.exists() + def resolve_app_memberships(self, info): # Find all EnvironmentKeys for this user user_env_keys = EnvironmentKey.objects.filter( @@ -540,7 +560,7 @@ def resolve_history(self, info): # Compute can_view_members only once per request organisation = self.environment.app.organisation can_view_members = user_has_permission( - user, "read", "Members", organisation, True + user, "read", "Members", organisation, True, app=self.environment.app ) or user_has_permission(user, "read", "Members", organisation, False) setattr(info.context, "can_view_members", can_view_members) @@ -601,9 +621,9 @@ def resolve_secrets(self, info, path=None): org = self.app.organisation if not user_has_permission( - info.context.user, "read", "Secrets", org, True + info.context.user, "read", "Secrets", org, True, app=self.app ) or not user_has_permission( - info.context.user, "read", "Environments", org, True + info.context.user, "read", "Environments", org, True, app=self.app ): raise GraphQLError("You don't have access to read secrets") @@ -771,6 +791,7 @@ class ServiceAccountType(DjangoObjectType): app_memberships = graphene.List(graphene.NonNull(AppMembershipType)) network_policies = graphene.List(graphene.NonNull(lambda: NetworkAccessPolicyType)) identities = graphene.List(graphene.NonNull(lambda: IdentityType)) + team = graphene.Field(lambda: TeamType) class Meta: model = ServiceAccount @@ -782,6 +803,7 @@ class Meta: "created_at", "updated_at", "deleted_at", + "team", ) def resolve_server_side_key_management_enabled(self, info): @@ -791,9 +813,29 @@ def resolve_server_side_key_management_enabled(self, info): ) def resolve_handlers(self, info): + # Gate so non-team-members can't enumerate team-owned SA + # handlers via teams.members.serviceAccount. + from api.utils.access.permissions import _check_sa_permission + try: + _check_sa_permission( + info.context.user, self, "read", "ServiceAccounts" + ) + except GraphQLError: + return [] return ServiceAccountHandler.objects.filter(service_account=self) def resolve_tokens(self, info): + # Gate raw token / wrapped_key_share / identity_key — exposed + # via fields="__all__" on ServiceAccountTokenType. Without this + # check, any user with Teams.read can harvest team-owned SA + # credentials cross-team. + from api.utils.access.permissions import _check_sa_permission + try: + _check_sa_permission( + info.context.user, self, "read", "ServiceAccountTokens" + ) + except GraphQLError: + return [] return ServiceAccountToken.objects.filter(service_account=self, deleted_at=None) def resolve_app_memberships(self, info): @@ -833,7 +875,22 @@ def resolve_identities(self, info): return self.identities.filter(deleted_at=None) +class EnvironmentKeyGrantType(DjangoObjectType): + """One key can carry multiple grants (individual + team).""" + + class Meta: + model = EnvironmentKeyGrant + fields = ( + "id", + "grant_type", + "team", + "created_at", + ) + + class EnvironmentKeyType(DjangoObjectType): + grants = graphene.List(graphene.NonNull(EnvironmentKeyGrantType)) + class Meta: model = EnvironmentKey fields = ( @@ -846,6 +903,9 @@ class Meta: "environment", ) + def resolve_grants(self, info): + return self.grants.all() + class ServerEnvironmentKeyType(DjangoObjectType): class Meta: @@ -1135,3 +1195,151 @@ def resolve_config(self, info): ) return None + + +class TeamAppEnvironmentType(DjangoObjectType): + class Meta: + model = TeamAppEnvironment + fields = ("id", "app", "environment", "created_at") + + +class TeamMembershipType(DjangoObjectType): + email = graphene.String() + full_name = graphene.String() + avatar_url = graphene.String() + + class Meta: + model = TeamMembership + fields = ("id", "org_member", "service_account", "created_at") + + def resolve_email(self, info): + if self.org_member: + return self.org_member.user.email + if self.service_account: + return self.service_account.name + return None + + def resolve_full_name(self, info): + if self.org_member: + social_acc = self.org_member.user.socialaccount_set.first() + if social_acc: + return social_acc.extra_data.get("name") + return None + + def resolve_avatar_url(self, info): + if self.org_member: + social_acc = self.org_member.user.socialaccount_set.first() + if social_acc: + if social_acc.provider == "google": + return social_acc.extra_data.get("picture") + return social_acc.extra_data.get("avatar_url") + return None + + +class TeamType(DjangoObjectType): + members = graphene.List(graphene.NonNull(TeamMembershipType)) + apps = graphene.List(graphene.NonNull(AppType)) + app_environments = graphene.List(graphene.NonNull(TeamAppEnvironmentType)) + member_count = graphene.Int() + + class Meta: + model = Team + fields = ( + "id", + "name", + "description", + "member_role", + "service_account_role", + "is_scim_managed", + "owner", + "created_by", + "created_at", + "updated_at", + ) + + def resolve_members(self, info): + return self.memberships.select_related( + "org_member__user", "service_account" + ).exclude( + org_member__isnull=False, org_member__deleted_at__isnull=False, + ).exclude( + service_account__isnull=False, service_account__deleted_at__isnull=False, + ) + + def resolve_apps(self, info): + # Gate to apps the caller can access — AppType exposes app_seed, + # wrapped_key_share, app_token, which would leak via teams. + user_id = info.context.user.userId + app_ids = ( + self.app_environments.values_list("app_id", flat=True).distinct() + ) + return [ + app + for app in App.objects.filter(id__in=app_ids, is_deleted=False) + if user_can_access_app(user_id, app.id) + ] + + def resolve_app_environments(self, info): + # Same gate as resolve_apps — don't leak the team→env matrix + # for apps the caller can't see. + user_id = info.context.user.userId + accessible_app_ids = { + app_id + for app_id in self.app_environments.values_list( + "app_id", flat=True + ).distinct() + if user_can_access_app(user_id, app_id) + } + return self.app_environments.select_related( + "app", "environment" + ).filter(app_id__in=accessible_app_ids) + + def resolve_member_count(self, info): + return self.memberships.exclude( + org_member__isnull=False, org_member__deleted_at__isnull=False, + ).exclude( + service_account__isnull=False, service_account__deleted_at__isnull=False, + ).count() + + +class SCIMTokenType(DjangoObjectType): + class Meta: + model = SCIMToken + fields = ( + "id", + "name", + "token_prefix", + "created_by", + "created_at", + "expires_at", + "last_used_at", + "is_active", + ) + + +class SCIMEventType(DjangoObjectType): + class Meta: + model = SCIMEvent + fields = ( + "id", + "scim_token", + "event_type", + "status", + "resource_type", + "resource_id", + "resource_name", + "detail", + "request_method", + "request_path", + "request_body", + "response_status", + "response_body", + "ip_address", + "user_agent", + "timestamp", + ) + + +class SCIMEventsResponseType(ObjectType): + events = graphene.List(SCIMEventType) + count = graphene.Int() diff --git a/backend/backend/quotas.py b/backend/backend/quotas.py index f053eb702..6df78cda7 100644 --- a/backend/backend/quotas.py +++ b/backend/backend/quotas.py @@ -134,6 +134,26 @@ def can_use_custom_envs(organisation): return organisation.plan != "FR" +def can_use_teams(organisation): + """Teams require a Pro or Enterprise plan (or an activated license).""" + ActivatedPhaseLicense = apps.get_model("api", "ActivatedPhaseLicense") + + if ActivatedPhaseLicense.objects.filter(organisation=organisation).exists(): + return True + + return organisation.plan in ("PR", "EN") + + +def can_use_scim(organisation): + """SCIM provisioning requires an Enterprise plan or activated license.""" + ActivatedPhaseLicense = apps.get_model("api", "ActivatedPhaseLicense") + + if ActivatedPhaseLicense.objects.filter(organisation=organisation).exists(): + return True + + return organisation.plan == "EN" + + def can_add_service_token(app): """Check if a new service token can be added to the app.""" diff --git a/backend/backend/schema.py b/backend/backend/schema.py index 3ce37a4f4..8c2fcc338 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -28,6 +28,7 @@ from backend.graphene.mutations.service_accounts import ( CreateServiceAccountMutation, CreateServiceAccountTokenMutation, + CreateServerSideServiceAccountTokenMutation, DeleteServiceAccountMutation, DeleteServiceAccountTokenMutation, EnableServiceAccountClientSideKeyManagementMutation, @@ -39,7 +40,11 @@ from .graphene.queries.syncing import ( resolve_vercel_projects, ) -from .graphene.mutations.syncing import CreateAzureKeyVaultSync, CreateRenderSync, CreateVercelSync +from .graphene.mutations.syncing import ( + CreateAzureKeyVaultSync, + CreateRenderSync, + CreateVercelSync, +) from .graphene.mutations.access import ( CreateCustomRoleMutation, CreateNetworkAccessPolicyMutation, @@ -124,6 +129,36 @@ from .graphene.queries.quotas import resolve_organisation_plan from .graphene.queries.license import resolve_license, resolve_organisation_license from .graphene.queries.auth import resolve_verify_password +from .graphene.queries.teams import resolve_teams + + +_SCIM_AVAILABLE = False +try: + from ee.authentication.scim.graphene.queries import ( + resolve_scim_tokens, + resolve_scim_events, + ) + from ee.authentication.scim.graphene.mutations import ( + CreateSCIMTokenMutation, + DeleteSCIMTokenMutation, + ToggleSCIMMutation, + ToggleSCIMTokenMutation, + ) + + _SCIM_AVAILABLE = True +except ImportError: + pass +from .graphene.mutations.teams import ( + AddTeamAppsMutation, + AddTeamMembersMutation, + CreateTeamMutation, + DeleteTeamMutation, + RemoveTeamAppMutation, + RemoveTeamMemberMutation, + TransferTeamOwnershipMutation, + UpdateTeamAppEnvironmentsMutation, + UpdateTeamMutation, +) from .graphene.mutations.environment import ( BulkCreateSecretMutation, BulkDeleteSecretMutation, @@ -225,6 +260,9 @@ ServiceAccountType, ServiceTokenType, ServiceType, + TeamType, + SCIMTokenType, + SCIMEventsResponseType, TimeRange, UserTokenType, AWSValidationResultType, @@ -247,6 +285,7 @@ SecretTag, ServiceAccount, ServiceToken, + TeamAppEnvironment, UserToken, ) from logs.queries import get_app_log_count, get_app_log_count_range, get_app_logs @@ -276,6 +315,29 @@ class Query(graphene.ObjectType): ) identities = graphene.List(IdentityType, organisation_id=graphene.ID()) + teams = graphene.List( + TeamType, + organisation_id=graphene.ID(), + team_id=graphene.ID(required=False), + ) + + # SCIM (Enterprise) + if _SCIM_AVAILABLE: + scim_tokens = graphene.List( + SCIMTokenType, + organisation_id=graphene.ID(), + ) + + scim_events = graphene.Field( + SCIMEventsResponseType, + organisation_id=graphene.ID(), + start=graphene.BigInt(required=False), + end=graphene.BigInt(required=False), + event_types=graphene.List(graphene.String, required=False), + token_id=graphene.ID(required=False), + status=graphene.String(required=False), + ) + organisation_name_available = graphene.Boolean(name=graphene.String()) verify_password = graphene.Boolean(auth_hash=graphene.String(required=True)) @@ -360,6 +422,7 @@ class Query(graphene.ObjectType): app_id=graphene.ID(required=False), environment_id=graphene.ID(required=False), member_id=graphene.ID(required=False), + member_type=MemberType(), ) environment_tokens = graphene.List( EnvironmentTokenType, environment_id=graphene.ID() @@ -558,6 +621,14 @@ def resolve_organisations(root, info): resolve_aws_sts_endpoints = resolve_aws_sts_endpoints resolve_identity_providers = resolve_identity_providers + # Teams + resolve_teams = resolve_teams + + # SCIM (Enterprise) + if _SCIM_AVAILABLE: + resolve_scim_tokens = resolve_scim_tokens + resolve_scim_events = resolve_scim_events + resolve_organisation_plan = resolve_organisation_plan def resolve_organisation_name_available(root, info, name): @@ -623,9 +694,22 @@ def resolve_apps(root, info, organisation_id, app_id=None): ): return [] + # Individual app IDs + individual_ids = set(org_member.apps.values_list("id", flat=True)) + + # Team app IDs + team_ids = set( + TeamAppEnvironment.objects.filter( + team__memberships__org_member=org_member, + team__deleted_at__isnull=True, + ).values_list("app_id", flat=True) + ) + + all_app_ids = individual_ids | team_ids + filter = { "organisation_id": organisation_id, - "id__in": org_member.apps.all(), + "id__in": all_app_ids, "is_deleted": False, } @@ -640,7 +724,7 @@ def resolve_app_environments( app = App.objects.get(id=app_id) if not user_has_permission( - info.context.user, "read", "Environments", app.organisation, True + info.context.user, "read", "Environments", app.organisation, True, app=app ): return [] @@ -673,21 +757,19 @@ def resolve_app_environments( accessible_env_ids = set( EnvironmentKey.objects.filter( - environment__app_id=app_id, **key_filter + environment__app_id=app_id, deleted_at__isnull=True, **key_filter ).values_list("environment_id", flat=True) ) return [ - app_env - for app_env in app_environments - if app_env.id in accessible_env_ids + app_env for app_env in app_environments if app_env.id in accessible_env_ids ] def resolve_app_users(root, info, app_id): app = App.objects.get(id=app_id) if not user_has_permission( - info.context.user, "read", "Members", app.organisation, True + info.context.user, "read", "Members", app.organisation, True, app=app ): raise GraphQLError("You don't have permission to read members of this App") @@ -700,11 +782,13 @@ def resolve_app_users(root, info, app_id): def resolve_secrets(root, info, env_id, path=None, id=None): - org = Environment.objects.get(id=env_id).app.organisation + env = Environment.objects.get(id=env_id) + app = env.app + org = app.organisation if not user_has_permission( - info.context.user, "read", "Secrets", org, True + info.context.user, "read", "Secrets", org, True, app=app ) or not user_has_permission( - info.context.user, "read", "Environments", org, True + info.context.user, "read", "Environments", org, True, app=app ): raise GraphQLError("You don't have access to read secrets") @@ -741,7 +825,12 @@ def resolve_secret_history(root, info, secret_id): # compute permission once and store it on the request context can_view_members = user_has_permission( - user, "read", "Members", secret.environment.app.organisation, True + user, + "read", + "Members", + secret.environment.app.organisation, + True, + app=secret.environment.app, ) or user_has_permission( user, "read", "Members", secret.environment.app.organisation, False ) @@ -760,7 +849,12 @@ def resolve_secret_tags(root, info, org_id): return SecretTag.objects.filter(organisation_id=org_id) def resolve_environment_keys( - root, info, app_id=None, environment_id=None, member_id=None + root, + info, + app_id=None, + environment_id=None, + member_id=None, + member_type=MemberType.USER, ): if app_id is None and environment_id is None: return None @@ -777,14 +871,22 @@ def resolve_environment_keys( if environment_id: filter["environment_id"] = environment_id - if member_id is not None: - org_member = OrganisationMember.objects.get(id=member_id, deleted_at=None) - else: - org_member = OrganisationMember.objects.get( - user=info.context.user, organisation=app.organisation, deleted_at=None + if member_id is not None and member_type == MemberType.SERVICE: + filter["service_account"] = ServiceAccount.objects.get( + id=member_id, deleted_at=None ) - - filter["user"] = org_member + else: + if member_id is not None: + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None + ) + else: + org_member = OrganisationMember.objects.get( + user=info.context.user, + organisation=app.organisation, + deleted_at=None, + ) + filter["user"] = org_member return EnvironmentKey.objects.filter(**filter) @@ -810,7 +912,7 @@ def resolve_user_tokens(root, info, organisation_id): def resolve_service_tokens(root, info, app_id): app = App.objects.get(id=app_id) if not user_has_permission( - info.context.user, "read", "Tokens", app.organisation, True + info.context.user, "read", "Tokens", app.organisation, True, app=app ): raise GraphQLError("You don't have permission to view Tokens in this App") @@ -911,11 +1013,13 @@ def resolve_secret_logs( # Permissions can_see_members = user_has_permission( - user, "read", "Members", app.organisation, True + user, "read", "Members", app.organisation, True, app=app ) or user_has_permission(user, "read", "Members", app.organisation, False) setattr(info.context, "can_view_members", can_see_members) - if not user_has_permission(user, "read", "Logs", app.organisation, True): + if not user_has_permission( + user, "read", "Logs", app.organisation, True, app=app + ): return SecretLogsResponseType(logs=[], count=0) # Base filter @@ -1114,6 +1218,24 @@ class Mutation(graphene.ObjectType): test_organisation_sso_provider = TestOrganisationSSOProviderMutation.Field() update_organisation_security = UpdateOrganisationSecurityMutation.Field() + # Teams + create_team = CreateTeamMutation.Field() + update_team = UpdateTeamMutation.Field() + transfer_team_ownership = TransferTeamOwnershipMutation.Field() + delete_team = DeleteTeamMutation.Field() + add_team_members = AddTeamMembersMutation.Field() + remove_team_member = RemoveTeamMemberMutation.Field() + add_team_apps = AddTeamAppsMutation.Field() + remove_team_app = RemoveTeamAppMutation.Field() + update_team_app_environments = UpdateTeamAppEnvironmentsMutation.Field() + + # SCIM (Enterprise) + if _SCIM_AVAILABLE: + create_scim_token = CreateSCIMTokenMutation.Field() + delete_scim_token = DeleteSCIMTokenMutation.Field() + toggle_scim = ToggleSCIMMutation.Field() + toggle_scim_token = ToggleSCIMTokenMutation.Field() + # Service Accounts create_service_account = CreateServiceAccountMutation.Field() enable_service_account_server_side_key_management = ( @@ -1126,6 +1248,7 @@ class Mutation(graphene.ObjectType): update_service_account = UpdateServiceAccountMutation.Field() delete_service_account = DeleteServiceAccountMutation.Field() create_service_account_token = CreateServiceAccountTokenMutation.Field() + create_server_side_service_account_token = CreateServerSideServiceAccountTokenMutation.Field() delete_service_account_token = DeleteServiceAccountTokenMutation.Field() init_env_sync = InitEnvSync.Field() diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 1e0ef9981..b3efcd9fa 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -193,6 +193,7 @@ def get_version(): } +SOCIALACCOUNT_ADAPTER = "api.authentication.adapters.social.AutoLinkSocialAccountAdapter" SOCIALACCOUNT_EMAIL_VERIFICATION = "none" SOCIALACCOUNT_EMAIL_REQUIRED = True SOCIALACCOUNT_QUERY_EMAIL = True diff --git a/backend/backend/urls.py b/backend/backend/urls.py index abeb5b3c8..b7d365cf1 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -73,6 +73,15 @@ path("identities/external/v1/azure/entra/auth/", azure_entra_auth), ] +# SCIM v2 Provisioning API +try: + scim_urls = [ + path("scim/v2/", include("ee.authentication.scim.urls")), + ] + urlpatterns.extend(scim_urls) +except ImportError: + pass + # Mount at root first (cloud: api.phase.dev/v1/...) so reverse() returns the # canonical form, then at /public/ for legacy clients and self-hosted nginx # (which forwards /service/public/... after stripping /service/). diff --git a/backend/conftest.py b/backend/conftest.py index a8cba84c0..211f77815 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -20,15 +20,19 @@ os.environ.setdefault("DATABASE_USER", "dummy_user") os.environ.setdefault("DATABASE_PASSWORD", "dummy_password") +# Django requires SECRET_KEY for middleware (sessions, CSRF, etc.) +os.environ.setdefault("SECRET_KEY", "dummy-secret-key-for-testing-only") +os.environ.setdefault("SERVER_SECRET", "a" * 64) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") def pytest_configure(): django.setup() - # Override cache to in-memory so tests don't require a running Redis from django.conf import settings + # Override cache to in-memory so tests don't require a running Redis settings.CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", diff --git a/backend/ee/authentication/scim/__init__.py b/backend/ee/authentication/scim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/ee/authentication/scim/auth.py b/backend/ee/authentication/scim/auth.py new file mode 100644 index 000000000..4307a4fff --- /dev/null +++ b/backend/ee/authentication/scim/auth.py @@ -0,0 +1,78 @@ +import hashlib +import logging + +from django.utils import timezone +from rest_framework import authentication, exceptions + +from api.models import SCIMToken +from backend.quotas import can_use_scim + +logger = logging.getLogger(__name__) + + +class SCIMServiceUser: + """Minimal user object satisfying DRF's request.user contract.""" + + def __init__(self, scim_token): + self.id = scim_token.id + self.is_authenticated = True + self.is_active = True + self.scim_token = scim_token + self.organisation = scim_token.organisation + + +class SCIMTokenAuthentication(authentication.BaseAuthentication): + """ + Authenticates SCIM v2 requests via Bearer token. + Header format: Authorization: Bearer + """ + + def authenticate(self, request): + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + + raw_token = auth_header[7:] + + # Only match SCIM tokens (ph_scim: prefix) + if not raw_token.startswith("ph_scim:"): + return None + + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + + try: + scim_token = SCIMToken.objects.select_related("organisation").get( + token_hash=token_hash, + deleted_at__isnull=True, + ) + except SCIMToken.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid SCIM token") + + if scim_token.expires_at and scim_token.expires_at < timezone.now(): + raise exceptions.AuthenticationFailed("SCIM token has expired") + + if not scim_token.organisation.scim_enabled: + raise exceptions.AuthenticationFailed( + "SCIM is not enabled for this organisation" + ) + + if not scim_token.is_active: + raise exceptions.AuthenticationFailed( + "This SCIM token is disabled" + ) + + if not can_use_scim(scim_token.organisation): + raise exceptions.AuthenticationFailed( + "SCIM provisioning requires an Enterprise plan" + ) + + # Track usage + scim_token.last_used_at = timezone.now() + scim_token.save(update_fields=["last_used_at"]) + + user = SCIMServiceUser(scim_token) + auth_info = { + "scim_token": scim_token, + "organisation": scim_token.organisation, + } + return (user, auth_info) diff --git a/backend/ee/authentication/scim/constants.py b/backend/ee/authentication/scim/constants.py new file mode 100644 index 000000000..3533e1f2c --- /dev/null +++ b/backend/ee/authentication/scim/constants.py @@ -0,0 +1,14 @@ +# SCIM v2 Schema URNs (RFC 7643) +SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +SCIM_PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA = ( + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" +) +SCIM_SCHEMA_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema" +SCIM_RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + +# Pagination defaults +SCIM_MAX_RESULTS = 100 +SCIM_DEFAULT_COUNT = 100 diff --git a/backend/ee/authentication/scim/exceptions.py b/backend/ee/authentication/scim/exceptions.py new file mode 100644 index 000000000..869a8575a --- /dev/null +++ b/backend/ee/authentication/scim/exceptions.py @@ -0,0 +1,35 @@ +from django.http import JsonResponse + +SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" + + +def scim_error(status, detail, scim_type=None): + """Return a SCIM-formatted error response (RFC 7644 Section 3.12).""" + body = { + "schemas": [SCIM_ERROR_SCHEMA], + "status": str(status), + "detail": detail, + } + if scim_type: + body["scimType"] = scim_type + return JsonResponse(body, status=status) + + +def scim_not_found(detail="Resource not found"): + return scim_error(404, detail) + + +def scim_conflict(detail="Resource already exists"): + return scim_error(409, detail, scim_type="uniqueness") + + +def scim_bad_request(detail="Invalid request"): + return scim_error(400, detail, scim_type="invalidValue") + + +def scim_forbidden(detail="Operation not permitted"): + return scim_error(403, detail) + + +def scim_server_error(detail="Internal server error"): + return scim_error(500, detail) diff --git a/backend/ee/authentication/scim/filters.py b/backend/ee/authentication/scim/filters.py new file mode 100644 index 000000000..bbf04ea65 --- /dev/null +++ b/backend/ee/authentication/scim/filters.py @@ -0,0 +1,79 @@ +import re + +# Cap filter input to bound regex matching time. Real SCIM filters are short +# (typically a single `attr eq "value"` clause); anything over this is rejected +# to prevent polynomial backtracking on adversarial whitespace-heavy input. +MAX_FILTER_LENGTH = 1024 + + +def parse_scim_filter(filter_string): + """ + Parse a minimal subset of SCIM filter expressions (RFC 7644 Section 3.4.2.2). + + Supports: + - eq operator: 'userName eq "user@example.com"' + - and conjunction: 'userName eq "foo" and externalId eq "bar"' + + Returns a list of (attribute, operator, value) tuples. + """ + if not filter_string or len(filter_string) > MAX_FILTER_LENGTH: + return [] + + clauses = [] + # Split on ' and ' (case-insensitive). Lookarounds keep the pattern + # non-backtracking: no `\s+` quantifier means no polynomial worst case. + parts = re.split(r"(?<=\s)and(?=\s)", filter_string, flags=re.IGNORECASE) + + for part in parts: + part = part.strip() + match = re.match( + r'^(\S+)\s+(eq|ne|co|sw|ew)\s+"([^"]*)"$', part, re.IGNORECASE + ) + if match: + attr, op, value = match.groups() + clauses.append((attr.lower(), op.lower(), value)) + + return clauses + + +def parse_patch_path_filter(path): + """ + Parse Azure Entra ID-style PATCH member removal path. + + Example: 'members[value eq "abc-123"]' + Returns the extracted value, or None if the path doesn't match. + """ + match = re.match(r'^members\[value\s+eq\s+"([^"]+)"\]$', path, re.IGNORECASE) + if match: + return match.group(1) + return None + + +# Maps SCIM user attribute names to Django ORM lookups +SCIM_USER_ATTR_MAP = { + "username": "email__iexact", + "externalid": "external_id", + "emails.value": "email__iexact", + "displayname": "display_name__iexact", +} + +# Maps SCIM group attribute names to Django ORM lookups +SCIM_GROUP_ATTR_MAP = { + "displayname": "display_name__iexact", + "externalid": "external_id", +} + + +def scim_filter_to_queryset(queryset, filter_string, attr_map): + """ + Apply SCIM filter clauses to a Django queryset. + Only 'eq' is supported — other operators are silently ignored. + """ + clauses = parse_scim_filter(filter_string) + for attr, op, value in clauses: + if op != "eq": + continue + lookup = attr_map.get(attr) + if lookup: + queryset = queryset.filter(**{lookup: value}) + return queryset diff --git a/backend/ee/authentication/scim/graphene/__init__.py b/backend/ee/authentication/scim/graphene/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/ee/authentication/scim/graphene/mutations.py b/backend/ee/authentication/scim/graphene/mutations.py new file mode 100644 index 000000000..377e25b0b --- /dev/null +++ b/backend/ee/authentication/scim/graphene/mutations.py @@ -0,0 +1,150 @@ +import hashlib +import secrets + +import graphene +from graphql import GraphQLError + +from api.models import Organisation, OrganisationMember, SCIMToken +from api.utils.access.permissions import user_has_permission +from backend.graphene.types import SCIMTokenType +from backend.quotas import can_use_scim +from django.utils import timezone + + +def _generate_scim_token(): + """Generate a SCIM bearer token: ph_scim:v1::.""" + prefix = secrets.token_hex(4) # 8 chars + body = secrets.token_hex(32) # 64 chars + full_token = f"ph_scim:v1:{prefix}:{body}" + token_hash = hashlib.sha256(full_token.encode()).hexdigest() + return full_token, token_hash, prefix + + +class CreateSCIMTokenMutation(graphene.Mutation): + class Arguments: + organisation_id = graphene.ID(required=True) + name = graphene.String(required=True) + expiry_days = graphene.Int(required=False) + + token = graphene.String() + scim_token = graphene.Field(SCIMTokenType) + + @classmethod + def mutate(cls, root, info, organisation_id, name, expiry_days=None): + org = Organisation.objects.get(id=organisation_id) + + # Check permission: Admin+ for SCIM token management + if not user_has_permission( + info.context.user, "update", "SCIM", org + ): + raise GraphQLError( + "You don't have permission to manage SCIM tokens." + ) + + if not can_use_scim(org): + raise GraphQLError( + "SCIM provisioning requires an Enterprise plan." + ) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None + ) + + full_token, token_hash, prefix = _generate_scim_token() + + expires_at = None + if expiry_days: + expires_at = timezone.now() + timezone.timedelta(days=expiry_days) + + scim_token = SCIMToken.objects.create( + organisation=org, + name=name.strip(), + token_hash=token_hash, + token_prefix=prefix, + created_by=org_member, + expires_at=expires_at, + ) + + return CreateSCIMTokenMutation(token=full_token, scim_token=scim_token) + + +class DeleteSCIMTokenMutation(graphene.Mutation): + class Arguments: + token_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, token_id): + scim_token = SCIMToken.objects.get(id=token_id, deleted_at__isnull=True) + org = scim_token.organisation + + if not user_has_permission( + info.context.user, "update", "SCIM", org + ): + raise GraphQLError( + "You don't have permission to manage SCIM tokens." + ) + + scim_token.deleted_at = timezone.now() + scim_token.save(update_fields=["deleted_at"]) + + return DeleteSCIMTokenMutation(ok=True) + + +class ToggleSCIMMutation(graphene.Mutation): + """Master switch: enable/disable SCIM for the organisation.""" + + class Arguments: + organisation_id = graphene.ID(required=True) + enabled = graphene.Boolean(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, organisation_id, enabled): + org = Organisation.objects.get(id=organisation_id) + + if not user_has_permission( + info.context.user, "update", "SCIM", org + ): + raise GraphQLError( + "You don't have permission to manage SCIM settings." + ) + + if not can_use_scim(org): + raise GraphQLError( + "SCIM provisioning requires an Enterprise plan." + ) + + org.scim_enabled = enabled + org.save(update_fields=["scim_enabled"]) + + return ToggleSCIMMutation(ok=True) + + +class ToggleSCIMTokenMutation(graphene.Mutation): + """Per-provider toggle: enable/disable a single SCIM token.""" + + class Arguments: + token_id = graphene.ID(required=True) + is_active = graphene.Boolean(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, token_id, is_active): + scim_token = SCIMToken.objects.get(id=token_id, deleted_at__isnull=True) + org = scim_token.organisation + + if not user_has_permission( + info.context.user, "update", "SCIM", org + ): + raise GraphQLError( + "You don't have permission to manage SCIM tokens." + ) + + scim_token.is_active = is_active + scim_token.save(update_fields=["is_active"]) + + return ToggleSCIMTokenMutation(ok=True) diff --git a/backend/ee/authentication/scim/graphene/queries.py b/backend/ee/authentication/scim/graphene/queries.py new file mode 100644 index 000000000..4ca543f3a --- /dev/null +++ b/backend/ee/authentication/scim/graphene/queries.py @@ -0,0 +1,78 @@ +from graphql import GraphQLError +from api.models import OrganisationMember, SCIMEvent, SCIMToken +from api.utils.access.permissions import user_has_permission, user_is_org_member +from datetime import datetime + + +def resolve_scim_tokens(root, info, organisation_id): + """Return SCIM tokens for the organisation. Requires SCIM.read permission (Admin+).""" + user = info.context.user + + if not user_is_org_member(user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") + + org_member = OrganisationMember.objects.get( + user_id=user.userId, organisation_id=organisation_id, deleted_at=None + ) + + if not user_has_permission(user, "read", "SCIM", org_member.organisation): + raise GraphQLError("You don't have permission to view SCIM tokens.") + + return SCIMToken.objects.filter( + organisation_id=organisation_id, deleted_at__isnull=True + ).order_by("-created_at") + + +PAGE_SIZE = 25 + + +def resolve_scim_events( + root, + info, + organisation_id, + start=None, + end=None, + event_types=None, + token_id=None, + status=None, +): + """Return SCIM audit events with cursor-based pagination and filtering. Requires SCIM.read permission.""" + user = info.context.user + + if not user_is_org_member(user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") + + org_member = OrganisationMember.objects.get( + user_id=user.userId, organisation_id=organisation_id, deleted_at=None + ) + + if not user_has_permission(user, "read", "SCIM", org_member.organisation): + raise GraphQLError("You don't have permission to view SCIM events.") + + qs = SCIMEvent.objects.filter(organisation_id=organisation_id) + + if start is not None: + qs = qs.filter(timestamp__gte=datetime.fromtimestamp(start / 1000)) + + if end is not None: + qs = qs.filter(timestamp__lte=datetime.fromtimestamp(end / 1000)) + + if event_types: + # Frontend sends uppercase enum values (e.g. USER_CREATED), + # DB stores lowercase (e.g. user_created) + lowered = [et.lower() for et in event_types] + qs = qs.filter(event_type__in=lowered) + + if token_id: + qs = qs.filter(scim_token_id=token_id) + + if status: + # Frontend sends uppercase (SUCCESS/ERROR), DB stores lowercase + qs = qs.filter(status=status.lower()) + + count = qs.count() + events = list( + qs.select_related("scim_token").order_by("-timestamp", "-id")[:PAGE_SIZE] + ) + + return {"events": events, "count": count} diff --git a/backend/ee/authentication/scim/logging.py b/backend/ee/authentication/scim/logging.py new file mode 100644 index 000000000..b30733e16 --- /dev/null +++ b/backend/ee/authentication/scim/logging.py @@ -0,0 +1,53 @@ +import json +import logging + +from api.models import SCIMEvent +from api.utils.access.ip import get_client_ip + +logger = logging.getLogger(__name__) + +MAX_BODY_SIZE = 32768 # 32KB + + +def log_scim_event( + request, + event_type, + resource_type, + resource_id="", + resource_name="", + detail=None, + status="success", + response_status=None, + response_body=None, +): + """Log a SCIM provisioning event.""" + try: + SCIMEvent.objects.create( + organisation=request.auth["organisation"], + scim_token=request.auth.get("scim_token"), + event_type=event_type, + status=status, + resource_type=resource_type, + resource_id=str(resource_id), + resource_name=resource_name, + detail=detail or {}, + request_method=request.method, + request_path=request.path, + request_body=_safe_parse_body(request), + response_status=response_status, + response_body=response_body, + ip_address=get_client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + except Exception: + logger.exception("Failed to log SCIM event") + + +def _safe_parse_body(request): + """Parse request body as JSON, returning None if not JSON or too large.""" + try: + if len(request.body) > MAX_BODY_SIZE: + return {"_truncated": True, "size": len(request.body)} + return json.loads(request.body) + except (json.JSONDecodeError, Exception): + return None diff --git a/backend/ee/authentication/scim/negotiation.py b/backend/ee/authentication/scim/negotiation.py new file mode 100644 index 000000000..c7c164445 --- /dev/null +++ b/backend/ee/authentication/scim/negotiation.py @@ -0,0 +1,14 @@ +from rest_framework.parsers import JSONParser +from rest_framework.renderers import JSONRenderer + + +class SCIMJSONRenderer(JSONRenderer): + """Renderer that accepts both application/scim+json and application/json.""" + + media_type = "application/scim+json" + + +class SCIMJSONParser(JSONParser): + """Parser that accepts both application/scim+json and application/json.""" + + media_type = "application/scim+json" diff --git a/backend/ee/authentication/scim/serializers.py b/backend/ee/authentication/scim/serializers.py new file mode 100644 index 000000000..2e392db7e --- /dev/null +++ b/backend/ee/authentication/scim/serializers.py @@ -0,0 +1,92 @@ +from ee.authentication.scim.constants import ( + SCIM_GROUP_SCHEMA, + SCIM_LIST_RESPONSE_SCHEMA, + SCIM_USER_SCHEMA, +) + + +def serialize_scim_user(scim_user, base_url=""): + """Serialize a SCIMUser model instance to a SCIM v2 User resource.""" + resource = { + "schemas": [SCIM_USER_SCHEMA], + "id": str(scim_user.id), + "externalId": scim_user.external_id, + "userName": scim_user.email, + "displayName": scim_user.display_name, + "active": scim_user.active, + "emails": [ + { + "value": scim_user.email, + "type": "work", + "primary": True, + } + ], + "meta": { + "resourceType": "User", + "created": ( + scim_user.created_at.isoformat() if scim_user.created_at else None + ), + "lastModified": ( + scim_user.updated_at.isoformat() if scim_user.updated_at else None + ), + "location": f"{base_url}/scim/v2/Users/{scim_user.id}", + }, + } + + # Include name if available from scim_data + name_data = scim_user.scim_data.get("name") + if name_data: + resource["name"] = name_data + + return resource + + +def serialize_scim_group(scim_group, base_url=""): + """Serialize a SCIMGroup model instance to a SCIM v2 Group resource.""" + members = [] + if scim_group.team: + from api.models import SCIMUser + + for membership in scim_group.team.memberships.select_related( + "org_member" + ).filter(org_member__isnull=False): + scim_user = SCIMUser.objects.filter( + org_member=membership.org_member, + organisation=scim_group.organisation, + ).first() + if scim_user: + members.append( + { + "value": str(scim_user.id), + "display": scim_user.display_name or scim_user.email, + } + ) + + return { + "schemas": [SCIM_GROUP_SCHEMA], + "id": str(scim_group.id), + "externalId": scim_group.external_id, + "displayName": scim_group.display_name, + "members": members, + "meta": { + "resourceType": "Group", + "created": ( + scim_group.created_at.isoformat() if scim_group.created_at else None + ), + "lastModified": ( + scim_group.updated_at.isoformat() if scim_group.updated_at else None + ), + "location": f"{base_url}/scim/v2/Groups/{scim_group.id}", + }, + } + + +def serialize_list_response(resources, total_results, start_index=1, items_per_page=100): + """Serialize a SCIM v2 ListResponse.""" + return { + "schemas": [SCIM_LIST_RESPONSE_SCHEMA], + "totalResults": total_results, + "startIndex": start_index, + "itemsPerPage": items_per_page, + "Resources": resources, + } diff --git a/backend/ee/authentication/scim/urls.py b/backend/ee/authentication/scim/urls.py new file mode 100644 index 000000000..34148c313 --- /dev/null +++ b/backend/ee/authentication/scim/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from ee.authentication.scim.views.discovery import ( + resource_types, + schemas, + service_provider_config, +) +from ee.authentication.scim.views.groups import groups_detail, groups_list +from ee.authentication.scim.views.users import users_detail, users_list + +urlpatterns = [ + # Discovery endpoints (RFC 7643) + path("ServiceProviderConfig", service_provider_config, name="scim-service-provider-config"), + path("Schemas", schemas, name="scim-schemas"), + path("ResourceTypes", resource_types, name="scim-resource-types"), + # User provisioning (RFC 7644) + path("Users", users_list, name="scim-users-list"), + path("Users/", users_detail, name="scim-users-detail"), + # Group provisioning (RFC 7644) + path("Groups", groups_list, name="scim-groups-list"), + path("Groups/", groups_detail, name="scim-groups-detail"), +] diff --git a/backend/ee/authentication/scim/utils.py b/backend/ee/authentication/scim/utils.py new file mode 100644 index 000000000..f6c189e6f --- /dev/null +++ b/backend/ee/authentication/scim/utils.py @@ -0,0 +1,124 @@ +import logging + +from django.utils import timezone + +from api.models import ( + CustomUser, + OrganisationMember, + Role, + SCIMUser, + TeamMembership, +) +from api.utils.keys import revoke_team_environment_keys + +logger = logging.getLogger(__name__) + + +def provision_scim_user(organisation, external_id, email, display_name, scim_data=None): + """ + Create or link a SCIM user to a Phase CustomUser + OrganisationMember. + + - If a CustomUser with this email exists, link to it. + - If an OrganisationMember exists but is soft-deleted, reactivate it. + - Otherwise create both from scratch. + + Returns the SCIMUser instance. + """ + email = email.lower().strip() + + # Get default role for SCIM-provisioned users + default_role = Role.objects.get( + organisation=organisation, name__iexact="developer" + ) + + # Try to find existing CustomUser by email + user = CustomUser.objects.filter(email__iexact=email).first() + + if user is None: + # Create new CustomUser (no password — SSO-only) + user = CustomUser.objects.create( + username=email, + email=email, + ) + user.set_unusable_password() + user.save() + + # Try to find existing OrganisationMember + org_member = OrganisationMember.objects.filter( + user=user, organisation=organisation + ).first() + + if org_member is None: + # Create pre-provisioned OrgMember (no identity_key yet) + org_member = OrganisationMember.objects.create( + user=user, + organisation=organisation, + role=default_role, + identity_key="", + wrapped_keyring="", + wrapped_recovery="", + ) + elif org_member.deleted_at is not None: + # Reactivate soft-deleted member + org_member.deleted_at = None + org_member.save(update_fields=["deleted_at"]) + + scim_user = SCIMUser.objects.create( + external_id=external_id, + organisation=organisation, + user=user, + org_member=org_member, + email=email, + display_name=display_name or "", + active=True, + scim_data=scim_data or {}, + ) + + return scim_user + + +def deactivate_scim_user(scim_user): + """ + Deactivate a SCIM user: soft-delete OrgMember and revoke team keys. + Does NOT delete CustomUser (may belong to other orgs). + """ + scim_user.active = False + scim_user.save(update_fields=["active"]) + + if scim_user.org_member: + # Revoke team environment keys for all teams + team_memberships = TeamMembership.objects.filter( + org_member=scim_user.org_member, + team__deleted_at__isnull=True, + ).select_related("team") + + for tm in team_memberships: + revoke_team_environment_keys(tm.team, member=scim_user.org_member) + + # Remove team memberships so reactivation doesn't restore stale teams. + # The IdP's group push will re-add the user to the correct teams. + team_memberships.delete() + + # Wipe crypto material so user must redo key ceremony if reactivated + scim_user.org_member.identity_key = "" + scim_user.org_member.wrapped_keyring = "" + scim_user.org_member.wrapped_recovery = "" + + # Soft-delete the org member + scim_user.org_member.deleted_at = timezone.now() + scim_user.org_member.save(update_fields=[ + "identity_key", "wrapped_keyring", "wrapped_recovery", "deleted_at", + ]) + + +def reactivate_scim_user(scim_user): + """ + Reactivate a previously deactivated SCIM user. + Team keys will be provisioned at next login via provision_pending_team_keys(). + """ + scim_user.active = True + scim_user.save(update_fields=["active"]) + + if scim_user.org_member and scim_user.org_member.deleted_at is not None: + scim_user.org_member.deleted_at = None + scim_user.org_member.save(update_fields=["deleted_at"]) diff --git a/backend/ee/authentication/scim/views/__init__.py b/backend/ee/authentication/scim/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/ee/authentication/scim/views/discovery.py b/backend/ee/authentication/scim/views/discovery.py new file mode 100644 index 000000000..e0363f048 --- /dev/null +++ b/backend/ee/authentication/scim/views/discovery.py @@ -0,0 +1,282 @@ +from django.http import JsonResponse +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, + renderer_classes, +) +from rest_framework.renderers import JSONRenderer + +from ee.authentication.scim.negotiation import SCIMJSONRenderer + +from ee.authentication.scim.constants import ( + SCIM_GROUP_SCHEMA, + SCIM_RESOURCE_TYPE_SCHEMA, + SCIM_SCHEMA_SCHEMA, + SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA, + SCIM_USER_SCHEMA, +) + + +@api_view(["GET"]) +@authentication_classes([]) +@permission_classes([]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +def service_provider_config(request): + """GET /scim/v2/ServiceProviderConfig — RFC 7643 Section 5.""" + return JsonResponse( + { + "schemas": [SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA], + "documentationUri": "https://docs.phase.dev/scim", + "patch": {"supported": True}, + "bulk": { + "supported": False, + "maxOperations": 0, + "maxPayloadSize": 0, + }, + "filter": { + "supported": True, + "maxResults": 100, + }, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, + "authenticationSchemes": [ + { + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Authentication scheme using a bearer token", + "specUri": "https://www.rfc-editor.org/info/rfc6750", + "primary": True, + } + ], + } + ) + + +@api_view(["GET"]) +@authentication_classes([]) +@permission_classes([]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +def schemas(request): + """GET /scim/v2/Schemas — RFC 7643 Section 7.""" + return JsonResponse( + { + "schemas": [SCIM_SCHEMA_SCHEMA], + "totalResults": 2, + "itemsPerPage": 2, + "startIndex": 1, + "Resources": [ + { + "id": SCIM_USER_SCHEMA, + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "userName", + "type": "string", + "multiValued": False, + "required": True, + "caseExact": False, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "server", + }, + { + "name": "displayName", + "type": "string", + "multiValued": False, + "required": False, + "caseExact": False, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + }, + { + "name": "active", + "type": "boolean", + "multiValued": False, + "required": False, + "mutability": "readWrite", + "returned": "default", + }, + { + "name": "emails", + "type": "complex", + "multiValued": True, + "required": True, + "mutability": "readWrite", + "returned": "default", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": False, + "required": True, + }, + { + "name": "type", + "type": "string", + "multiValued": False, + "required": False, + }, + { + "name": "primary", + "type": "boolean", + "multiValued": False, + "required": False, + }, + ], + }, + { + "name": "name", + "type": "complex", + "multiValued": False, + "required": False, + "mutability": "readWrite", + "returned": "default", + "subAttributes": [ + { + "name": "givenName", + "type": "string", + "multiValued": False, + "required": False, + }, + { + "name": "familyName", + "type": "string", + "multiValued": False, + "required": False, + }, + { + "name": "formatted", + "type": "string", + "multiValued": False, + "required": False, + }, + ], + }, + { + "name": "externalId", + "type": "string", + "multiValued": False, + "required": False, + "caseExact": True, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "global", + }, + ], + "meta": { + "resourceType": "Schema", + "location": "/scim/v2/Schemas/" + SCIM_USER_SCHEMA, + }, + }, + { + "id": SCIM_GROUP_SCHEMA, + "name": "Group", + "description": "Group (maps to Phase Team)", + "attributes": [ + { + "name": "displayName", + "type": "string", + "multiValued": False, + "required": True, + "caseExact": False, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "required": False, + "caseExact": False, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + }, + { + "name": "members", + "type": "complex", + "multiValued": True, + "required": False, + "mutability": "readWrite", + "returned": "default", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": False, + "required": True, + "mutability": "immutable", + }, + { + "name": "display", + "type": "string", + "multiValued": False, + "required": False, + "mutability": "readOnly", + }, + ], + }, + { + "name": "externalId", + "type": "string", + "multiValued": False, + "required": False, + "caseExact": True, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "global", + }, + ], + "meta": { + "resourceType": "Schema", + "location": "/scim/v2/Schemas/" + SCIM_GROUP_SCHEMA, + }, + }, + ], + } + ) + + +@api_view(["GET"]) +@authentication_classes([]) +@permission_classes([]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +def resource_types(request): + """GET /scim/v2/ResourceTypes — RFC 7643 Section 6.""" + return JsonResponse( + { + "schemas": [SCIM_RESOURCE_TYPE_SCHEMA], + "totalResults": 2, + "itemsPerPage": 2, + "startIndex": 1, + "Resources": [ + { + "schemas": [SCIM_RESOURCE_TYPE_SCHEMA], + "id": "User", + "name": "User", + "endpoint": "/scim/v2/Users", + "schema": SCIM_USER_SCHEMA, + "meta": { + "resourceType": "ResourceType", + "location": "/scim/v2/ResourceTypes/User", + }, + }, + { + "schemas": [SCIM_RESOURCE_TYPE_SCHEMA], + "id": "Group", + "name": "Group", + "endpoint": "/scim/v2/Groups", + "schema": SCIM_GROUP_SCHEMA, + "meta": { + "resourceType": "ResourceType", + "location": "/scim/v2/ResourceTypes/Group", + }, + }, + ], + } + ) diff --git a/backend/ee/authentication/scim/views/groups.py b/backend/ee/authentication/scim/views/groups.py new file mode 100644 index 000000000..a138ae674 --- /dev/null +++ b/backend/ee/authentication/scim/views/groups.py @@ -0,0 +1,410 @@ +import json +import logging + +from django.db import IntegrityError +from django.http import HttpResponse, JsonResponse +from rest_framework.decorators import ( + api_view, + authentication_classes, + parser_classes, + permission_classes, + renderer_classes, +) +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import JSONRenderer + +from ee.authentication.scim.negotiation import SCIMJSONParser, SCIMJSONRenderer + +from api.models import ( + SCIMGroup, + SCIMUser, + ServiceAccount, + ServiceAccountToken, + Team, + TeamAppEnvironment, + TeamMembership, +) +from api.utils.keys import provision_team_environment_keys, revoke_team_environment_keys +from ee.authentication.scim.auth import SCIMTokenAuthentication +from ee.authentication.scim.constants import SCIM_DEFAULT_COUNT +from ee.authentication.scim.exceptions import ( + scim_bad_request, + scim_conflict, + scim_not_found, + scim_server_error, +) +from ee.authentication.scim.logging import log_scim_event +from ee.authentication.scim.filters import ( + SCIM_GROUP_ATTR_MAP, + parse_patch_path_filter, + scim_filter_to_queryset, +) +from ee.authentication.scim.serializers import ( + serialize_list_response, + serialize_scim_group, +) + +logger = logging.getLogger(__name__) + + +def _get_base_url(request): + return f"https://{request.get_host()}/service" + + +def _provision_keys_for_membership(team, membership): + """Provision environment keys for a new team member across all team apps.""" + app_ids = ( + TeamAppEnvironment.objects.filter(team=team) + .values_list("app_id", flat=True) + .distinct() + ) + for app_id in app_ids: + from api.models import App + + try: + app = App.objects.get(id=app_id, is_deleted=False) + if app.sse_enabled: + provision_team_environment_keys(team, app, members=[membership]) + except App.DoesNotExist: + continue + except Exception: + logger.exception("Failed to provision keys for team %s, app %s", team.id, app_id) + + +def _add_member_to_team(team, scim_user, org): + """Add a SCIM user to a team, creating TeamMembership and provisioning keys.""" + if not scim_user.org_member: + return None + + membership, created = TeamMembership.objects.get_or_create( + team=team, + org_member=scim_user.org_member, + ) + + if created: + _provision_keys_for_membership(team, membership) + + return membership + + +def _remove_member_from_team(team, scim_user): + """Remove a SCIM user from a team, revoking keys and deleting membership.""" + if not scim_user.org_member: + return + + membership = TeamMembership.objects.filter( + team=team, org_member=scim_user.org_member + ).first() + + if membership: + revoke_team_environment_keys(team, member=scim_user.org_member) + membership.delete() + + +@api_view(["GET", "POST"]) +@authentication_classes([SCIMTokenAuthentication]) +@permission_classes([IsAuthenticated]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +@parser_classes([SCIMJSONParser, JSONParser]) +def groups_list(request): + """ + GET /scim/v2/Groups — List/filter groups + POST /scim/v2/Groups — Create a new group + """ + org = request.auth["organisation"] + + if request.method == "GET": + return _list_groups(request, org) + else: + return _create_group(request, org) + + +@api_view(["GET", "PUT", "PATCH", "DELETE"]) +@authentication_classes([SCIMTokenAuthentication]) +@permission_classes([IsAuthenticated]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +@parser_classes([SCIMJSONParser, JSONParser]) +def groups_detail(request, scim_group_id): + """ + GET /scim/v2/Groups/:id — Get group + PUT /scim/v2/Groups/:id — Replace group + PATCH /scim/v2/Groups/:id — Partial update (Azure Entra's primary method) + DELETE /scim/v2/Groups/:id — Delete group + """ + org = request.auth["organisation"] + + try: + scim_group = SCIMGroup.objects.select_related("team").get( + id=scim_group_id, organisation=org + ) + except SCIMGroup.DoesNotExist: + return scim_not_found("Group not found") + + if request.method == "GET": + return JsonResponse( + serialize_scim_group(scim_group, _get_base_url(request)) + ) + elif request.method == "PUT": + return _replace_group(request, scim_group) + elif request.method == "PATCH": + return _patch_group(request, scim_group) + elif request.method == "DELETE": + return _delete_group(request, scim_group) + + +def _list_groups(request, org): + filter_str = request.GET.get("filter", "") + start_index = max(int(request.GET.get("startIndex", 1)), 1) + count = min(int(request.GET.get("count", SCIM_DEFAULT_COUNT)), SCIM_DEFAULT_COUNT) + + qs = SCIMGroup.objects.filter(organisation=org).order_by("created_at") + if filter_str: + qs = scim_filter_to_queryset(qs, filter_str, SCIM_GROUP_ATTR_MAP) + + total = qs.count() + offset = start_index - 1 + page = qs[offset : offset + count] + + base_url = _get_base_url(request) + resources = [serialize_scim_group(g, base_url) for g in page] + return JsonResponse(serialize_list_response(resources, total, start_index, count)) + + +def _create_group(request, org): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return scim_bad_request("Invalid JSON body") + + display_name = data.get("displayName", "").strip() + external_id = data.get("externalId", "") + description = (data.get("description") or "").strip() or None + + if not display_name: + return scim_bad_request("displayName is required") + if not external_id: + import uuid + external_id = str(uuid.uuid4()) + + # Create Team + try: + team = Team.objects.create( + name=display_name[:64], + description=description, + organisation=org, + is_scim_managed=True, + created_by=None, + ) + except Exception: + logger.exception("Failed to create team for SCIM group") + return scim_server_error() + + # Create SCIMGroup + try: + scim_group = SCIMGroup.objects.create( + external_id=external_id, + organisation=org, + team=team, + display_name=display_name, + scim_data=data, + ) + except IntegrityError: + team.delete() + log_scim_event( + request, "group_created", "group", "", display_name, + status="error", response_status=409, + response_body={"detail": f"Group with externalId '{external_id}' already exists"}, + ) + return scim_conflict( + f"Group with externalId '{external_id}' already exists" + ) + + # Add initial members + members = data.get("members", []) + for member_ref in members: + member_id = member_ref.get("value") + if member_id: + scim_user = SCIMUser.objects.filter( + id=member_id, organisation=org + ).first() + if scim_user: + _add_member_to_team(team, scim_user, org) + + response_data = serialize_scim_group(scim_group, _get_base_url(request)) + log_scim_event( + request, "group_created", "group", scim_group.id, display_name, + response_status=201, response_body=response_data, + ) + return JsonResponse(response_data, status=201) + + +def _replace_group(request, scim_group): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return scim_bad_request("Invalid JSON body") + + org = scim_group.organisation + display_name = data.get("displayName", "").strip() + external_id = data.get("externalId", scim_group.external_id) + description = (data.get("description") or "").strip() or None + + if display_name: + scim_group.display_name = display_name + if scim_group.team: + scim_group.team.name = display_name[:64] + scim_group.team.description = description + scim_group.team.save(update_fields=["name", "description", "updated_at"]) + + scim_group.external_id = external_id + scim_group.scim_data = data + scim_group.save() + + if scim_group.team: + # Diff membership: incoming vs current + incoming_ids = {m.get("value") for m in data.get("members", []) if m.get("value")} + + current_memberships = TeamMembership.objects.filter( + team=scim_group.team, org_member__isnull=False + ).select_related("org_member") + + current_scim_ids = set() + for tm in current_memberships: + scim_user = SCIMUser.objects.filter( + org_member=tm.org_member, organisation=org + ).first() + if scim_user: + current_scim_ids.add(scim_user.id) + + # Add new members + for scim_id in incoming_ids - current_scim_ids: + scim_user = SCIMUser.objects.filter(id=scim_id, organisation=org).first() + if scim_user: + _add_member_to_team(scim_group.team, scim_user, org) + + # Remove departed members + for scim_id in current_scim_ids - incoming_ids: + scim_user = SCIMUser.objects.filter(id=scim_id, organisation=org).first() + if scim_user: + _remove_member_from_team(scim_group.team, scim_user) + + response_data = serialize_scim_group(scim_group, _get_base_url(request)) + log_scim_event( + request, "group_updated", "group", scim_group.id, scim_group.display_name, + response_status=200, response_body=response_data, + ) + return JsonResponse(response_data) + + +def _patch_group(request, scim_group): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return scim_bad_request("Invalid JSON body") + + org = scim_group.organisation + operations = data.get("Operations", []) + if not operations: + return scim_bad_request("No operations provided") + + for op in operations: + op_type = op.get("op", "").lower() + path = op.get("path", "") + value = op.get("value") + + if op_type in ("replace", "add") and path.lower() == "displayname": + scim_group.display_name = value + scim_group.save(update_fields=["display_name"]) + if scim_group.team: + scim_group.team.name = str(value)[:64] + scim_group.team.save(update_fields=["name", "updated_at"]) + + elif op_type in ("replace", "add") and path.lower() == "description": + if scim_group.team: + scim_group.team.description = (str(value).strip() if value else None) + scim_group.team.save(update_fields=["description", "updated_at"]) + + elif op_type == "add" and path.lower() == "members": + members = value if isinstance(value, list) else [value] + for member_ref in members: + member_id = member_ref.get("value") if isinstance(member_ref, dict) else member_ref + if member_id and scim_group.team: + scim_user = SCIMUser.objects.filter( + id=member_id, organisation=org + ).first() + if scim_user: + _add_member_to_team(scim_group.team, scim_user, org) + log_scim_event( + request, "member_added", "group", scim_group.id, + scim_group.display_name, + detail={"member_email": scim_user.email, "member_id": str(scim_user.id)}, + response_status=200, + ) + + elif op_type == "remove": + if not scim_group.team: + continue + + member_ids = [] + + # Azure Entra format: members[value eq "abc-123"] + filtered_id = parse_patch_path_filter(path) + if filtered_id: + member_ids.append(filtered_id) + + # Okta format: path="members", value=[{"value": "id"}] + if path.lower() == "members" and value: + refs = value if isinstance(value, list) else [value] + for ref in refs: + mid = ref.get("value") if isinstance(ref, dict) else ref + if mid: + member_ids.append(mid) + + for member_id in member_ids: + scim_user = SCIMUser.objects.filter( + id=member_id, organisation=org + ).first() + if scim_user: + _remove_member_from_team(scim_group.team, scim_user) + log_scim_event( + request, "member_removed", "group", scim_group.id, + scim_group.display_name, + detail={"member_email": scim_user.email, "member_id": str(scim_user.id)}, + response_status=200, + ) + + response_data = serialize_scim_group(scim_group, _get_base_url(request)) + log_scim_event( + request, "group_updated", "group", scim_group.id, scim_group.display_name, + response_status=200, response_body=response_data, + ) + return JsonResponse(response_data) + + +def _delete_group(request, scim_group): + log_scim_event( + request, "group_deleted", "group", scim_group.id, scim_group.display_name, + response_status=204, + ) + if scim_group.team: + from django.utils import timezone + now = timezone.now() + + revoke_team_environment_keys(scim_group.team) + + # Soft-delete team-owned service accounts and their tokens — mirrors DeleteTeamMutation + for sa in ServiceAccount.objects.filter( + team=scim_group.team, deleted_at__isnull=True + ): + sa.deleted_at = now + sa.save() + ServiceAccountToken.objects.filter( + service_account=sa, deleted_at__isnull=True + ).update(deleted_at=now) + + scim_group.team.deleted_at = now + scim_group.team.save(update_fields=["deleted_at"]) + + scim_group.delete() + return HttpResponse(status=204) diff --git a/backend/ee/authentication/scim/views/users.py b/backend/ee/authentication/scim/views/users.py new file mode 100644 index 000000000..08b78b8e3 --- /dev/null +++ b/backend/ee/authentication/scim/views/users.py @@ -0,0 +1,328 @@ +import json +import logging + +from django.db import IntegrityError +from django.http import JsonResponse +from django.http import HttpResponse +from rest_framework.decorators import ( + api_view, + authentication_classes, + parser_classes, + permission_classes, + renderer_classes, +) +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import JSONRenderer + +from ee.authentication.scim.negotiation import SCIMJSONParser, SCIMJSONRenderer + +from api.models import SCIMUser +from backend.quotas import can_add_account +from ee.authentication.scim.auth import SCIMTokenAuthentication +from ee.authentication.scim.exceptions import ( + scim_bad_request, + scim_conflict, + scim_forbidden, + scim_not_found, + scim_server_error, +) +from ee.authentication.scim.filters import ( + SCIM_USER_ATTR_MAP, + scim_filter_to_queryset, +) +from ee.authentication.scim.constants import SCIM_DEFAULT_COUNT +from ee.authentication.scim.serializers import ( + serialize_list_response, + serialize_scim_user, +) +from ee.authentication.scim.logging import log_scim_event +from ee.authentication.scim.utils import ( + deactivate_scim_user, + provision_scim_user, + reactivate_scim_user, +) + +logger = logging.getLogger(__name__) + + +def _get_base_url(request): + return f"https://{request.get_host()}/service" + + +def _extract_user_fields(data): + """Extract user fields from a SCIM User resource.""" + email = data.get("userName", "").strip() + if not email: + # Try emails array + emails = data.get("emails", []) + for e in emails: + if e.get("primary") or not email: + email = e.get("value", "").strip() + + external_id = data.get("externalId", "") + display_name = data.get("displayName", "") + + # Build display name from name object if not provided + if not display_name: + name = data.get("name", {}) + parts = [name.get("givenName", ""), name.get("familyName", "")] + display_name = " ".join(p for p in parts if p).strip() + + active = data.get("active", True) + + return email, external_id, display_name, active + + +@api_view(["GET", "POST"]) +@authentication_classes([SCIMTokenAuthentication]) +@permission_classes([IsAuthenticated]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +@parser_classes([SCIMJSONParser, JSONParser]) +def users_list(request): + """ + GET /scim/v2/Users — List/filter users + POST /scim/v2/Users — Create a new user + """ + org = request.auth["organisation"] + + if request.method == "GET": + return _list_users(request, org) + else: + return _create_user(request, org) + + +@api_view(["GET", "PUT", "PATCH", "DELETE"]) +@authentication_classes([SCIMTokenAuthentication]) +@permission_classes([IsAuthenticated]) +@renderer_classes([SCIMJSONRenderer, JSONRenderer]) +@parser_classes([SCIMJSONParser, JSONParser]) +def users_detail(request, scim_user_id): + """ + GET /scim/v2/Users/:id — Get user + PUT /scim/v2/Users/:id — Replace user + PATCH /scim/v2/Users/:id — Partial update + DELETE /scim/v2/Users/:id — Deactivate user + """ + org = request.auth["organisation"] + + try: + scim_user = SCIMUser.objects.select_related("user", "org_member").get( + id=scim_user_id, organisation=org + ) + except SCIMUser.DoesNotExist: + return scim_not_found("User not found") + + if request.method == "GET": + return JsonResponse( + serialize_scim_user(scim_user, _get_base_url(request)) + ) + elif request.method == "PUT": + return _replace_user(request, scim_user) + elif request.method == "PATCH": + return _patch_user(request, scim_user) + elif request.method == "DELETE": + return _delete_user(request, scim_user) + + +def _list_users(request, org): + filter_str = request.GET.get("filter", "") + start_index = max(int(request.GET.get("startIndex", 1)), 1) + count = min(int(request.GET.get("count", SCIM_DEFAULT_COUNT)), SCIM_DEFAULT_COUNT) + + qs = SCIMUser.objects.filter(organisation=org).order_by("created_at") + if filter_str: + qs = scim_filter_to_queryset(qs, filter_str, SCIM_USER_ATTR_MAP) + + total = qs.count() + # SCIM pagination is 1-indexed + offset = start_index - 1 + page = qs[offset : offset + count] + + base_url = _get_base_url(request) + resources = [serialize_scim_user(u, base_url) for u in page] + return JsonResponse(serialize_list_response(resources, total, start_index, count)) + + +def _create_user(request, org): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return scim_bad_request("Invalid JSON body") + + email, external_id, display_name, active = _extract_user_fields(data) + + if not email: + return scim_bad_request("userName (email) is required") + if not external_id: + return scim_bad_request("externalId is required") + + # Check seat quota + if not can_add_account(org): + return scim_forbidden("Organisation has reached its seat limit") + + try: + scim_user = provision_scim_user( + organisation=org, + external_id=external_id, + email=email, + display_name=display_name, + scim_data=data, + ) + except IntegrityError: + log_scim_event( + request, "user_created", "user", "", email, + status="error", response_status=409, + response_body={"detail": f"User with externalId '{external_id}' or email '{email}' already exists"}, + ) + return scim_conflict( + f"User with externalId '{external_id}' or email '{email}' already exists" + ) + except Exception as e: + logger.exception("SCIM user creation failed") + log_scim_event( + request, "user_created", "user", "", email, + status="error", response_status=500, + response_body={"detail": str(e)}, + ) + return scim_server_error() + + if not active: + deactivate_scim_user(scim_user) + + response_data = serialize_scim_user(scim_user, _get_base_url(request)) + log_scim_event( + request, "user_created", "user", scim_user.id, email, + response_status=201, response_body=response_data, + ) + return JsonResponse(response_data, status=201) + + +def _replace_user(request, scim_user): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return scim_bad_request("Invalid JSON body") + + email, external_id, display_name, active = _extract_user_fields(data) + + if email and email != scim_user.email: + scim_user.email = email + if scim_user.user: + scim_user.user.email = email + scim_user.user.username = email + scim_user.user.save(update_fields=["email", "username"]) + + if external_id: + scim_user.external_id = external_id + if display_name: + scim_user.display_name = display_name + + scim_user.scim_data = data + scim_user.save() + + # Handle activation state change + event_type = "user_updated" + if active and not scim_user.active: + reactivate_scim_user(scim_user) + event_type = "user_reactivated" + elif not active and scim_user.active: + deactivate_scim_user(scim_user) + event_type = "user_deactivated" + + response_data = serialize_scim_user(scim_user, _get_base_url(request)) + log_scim_event( + request, event_type, "user", scim_user.id, scim_user.email, + response_status=200, response_body=response_data, + ) + return JsonResponse(response_data) + + +def _patch_user(request, scim_user): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return scim_bad_request("Invalid JSON body") + + operations = data.get("Operations", []) + if not operations: + return scim_bad_request("No operations provided") + + for op in operations: + op_type = op.get("op", "").lower() + path = op.get("path", "").lower() + value = op.get("value") + + if op_type == "replace": + if path == "active": + active = value if isinstance(value, bool) else str(value).lower() == "true" + if active and not scim_user.active: + reactivate_scim_user(scim_user) + elif not active and scim_user.active: + deactivate_scim_user(scim_user) + elif path == "username": + scim_user.email = value + if scim_user.user: + scim_user.user.email = value + scim_user.user.username = value + scim_user.user.save(update_fields=["email", "username"]) + scim_user.save(update_fields=["email"]) + elif path == "displayname": + scim_user.display_name = value + scim_user.save(update_fields=["display_name"]) + elif path == "name.givenname" or path == "name.familyname": + name_data = scim_user.scim_data.get("name", {}) + if path == "name.givenname": + name_data["givenName"] = value + else: + name_data["familyName"] = value + scim_user.scim_data["name"] = name_data + # Update display_name from name parts + given = name_data.get("givenName", "") + family = name_data.get("familyName", "") + scim_user.display_name = f"{given} {family}".strip() + scim_user.save(update_fields=["scim_data", "display_name"]) + elif not path: + # Valueless replace — value is a dict of attributes + if isinstance(value, dict): + if "active" in value: + active = value["active"] + if isinstance(active, str): + active = active.lower() == "true" + if active and not scim_user.active: + reactivate_scim_user(scim_user) + elif not active and scim_user.active: + deactivate_scim_user(scim_user) + + response_data = serialize_scim_user(scim_user, _get_base_url(request)) + + # Determine the most specific event type from operations + event_type = "user_updated" + for op in operations: + op_type = op.get("op", "").lower() + path = op.get("path", "").lower() + value = op.get("value") + if op_type == "replace" and path == "active": + active = value if isinstance(value, bool) else str(value).lower() == "true" + event_type = "user_reactivated" if active else "user_deactivated" + elif op_type == "replace" and not path and isinstance(value, dict) and "active" in value: + active = value["active"] + if isinstance(active, str): + active = active.lower() == "true" + event_type = "user_reactivated" if active else "user_deactivated" + + log_scim_event( + request, event_type, "user", scim_user.id, scim_user.email, + response_status=200, response_body=response_data, + ) + return JsonResponse(response_data) + + +def _delete_user(request, scim_user): + if scim_user.active: + deactivate_scim_user(scim_user) + log_scim_event( + request, "user_deactivated", "user", scim_user.id, scim_user.email, + response_status=204, + ) + return HttpResponse(status=204) diff --git a/backend/ee/integrations/secrets/dynamic/aws/graphene/mutations.py b/backend/ee/integrations/secrets/dynamic/aws/graphene/mutations.py index ccc510be6..3fa86c882 100644 --- a/backend/ee/integrations/secrets/dynamic/aws/graphene/mutations.py +++ b/backend/ee/integrations/secrets/dynamic/aws/graphene/mutations.py @@ -75,15 +75,14 @@ def mutate( raise GraphQLError("You don't have access to this organisation") org = Organisation.objects.get(id=organisation_id) + env = Environment.objects.get(id=environment_id) - if not user_has_permission(user, "create", "Secrets", org, True): + if not user_has_permission(user, "create", "Secrets", org, True, app=env.app): raise GraphQLError("You don't have permission to create Dynamic Secrets") if not user_can_access_environment(user.userId, environment_id): raise GraphQLError("You don't have access to this environment") - env = Environment.objects.get(id=environment_id) - if not env.app.sse_enabled: raise GraphQLError("SSE is not enabled!") @@ -166,7 +165,7 @@ def mutate( env = Environment.objects.get(id=dynamic_secret.environment.id) - if not user_has_permission(user, "update", "Secrets", org, True): + if not user_has_permission(user, "update", "Secrets", org, True, app=env.app): raise GraphQLError("You don't have permission to update Dynamic Secrets") if not user_can_access_environment(user.userId, dynamic_secret.environment.id): diff --git a/backend/ee/integrations/secrets/dynamic/graphene/mutations.py b/backend/ee/integrations/secrets/dynamic/graphene/mutations.py index 93b60f8a4..eaa4cc008 100644 --- a/backend/ee/integrations/secrets/dynamic/graphene/mutations.py +++ b/backend/ee/integrations/secrets/dynamic/graphene/mutations.py @@ -49,7 +49,7 @@ def mutate( org = secret.environment.app.organisation # --- permission checks --- - if not user_has_permission(user, "delete", "Secrets", org, True): + if not user_has_permission(user, "delete", "Secrets", org, True, app=secret.environment.app): raise GraphQLError( "You don't have permission to delete secrets in this organisation" ) @@ -85,7 +85,7 @@ def mutate( if not user_is_org_member(user.userId, org.id): raise GraphQLError("You don't have access to this organisation") - if not user_has_permission(user, "create", "Secrets", org, True): + if not user_has_permission(user, "create", "Secrets", org, True, app=secret.environment.app): raise GraphQLError("You don't have permission to create Dynamic Secrets") if not user_can_access_environment(user.userId, secret.environment.id): @@ -136,7 +136,7 @@ def mutate( lease.organisation_member is None or lease.organisation_member.id != org_member.id ) and not user_has_permission( - info.context.user, "update", "DynamicSecretLeases", org, True + info.context.user, "update", "DynamicSecretLeases", org, True, app=lease.secret.environment.app ): raise GraphQLError( "You cannot renew this lease as it wasn't created by you" @@ -180,7 +180,7 @@ def mutate( lease.organisation_member is None or lease.organisation_member.id != org_member.id ) and not user_has_permission( - info.context.user, "delete", "DynamicSecretLeases", org, True + info.context.user, "delete", "DynamicSecretLeases", org, True, app=lease.secret.environment.app ): raise GraphQLError( "You cannot revoke this lease as it wasn't created by you" diff --git a/backend/ee/integrations/secrets/dynamic/graphene/queries.py b/backend/ee/integrations/secrets/dynamic/graphene/queries.py index bcbbb9c21..335790954 100644 --- a/backend/ee/integrations/secrets/dynamic/graphene/queries.py +++ b/backend/ee/integrations/secrets/dynamic/graphene/queries.py @@ -35,6 +35,7 @@ def resolve_dynamic_secrets( elif path is not None: filters["path"] = path org = None + app = None # Figure out which org to use if app_id: @@ -42,7 +43,8 @@ def resolve_dynamic_secrets( org = app.organisation elif env_id: env = Environment.objects.get(id=env_id) - org = env.app.organisation + app = env.app + org = app.organisation elif org_id: org = Organisation.objects.get(id=org_id) else: @@ -51,7 +53,7 @@ def resolve_dynamic_secrets( ) # Permission check (common to all cases) - if not user_has_permission(user, "read", "Secrets", org, True): + if not user_has_permission(user, "read", "Secrets", org, True, app=app): return [] # Build filters + access checks diff --git a/backend/ee/integrations/secrets/dynamic/graphene/types.py b/backend/ee/integrations/secrets/dynamic/graphene/types.py index 2a5ca0770..5826739cf 100644 --- a/backend/ee/integrations/secrets/dynamic/graphene/types.py +++ b/backend/ee/integrations/secrets/dynamic/graphene/types.py @@ -89,6 +89,7 @@ def resolve_leases(self, info): "DynamicSecretLeases", self.environment.app.organisation, True, + app=self.environment.app, ): filter["organisation_member"] = OrganisationMember.objects.get( organisation=self.environment.app.organisation, user=info.context.user diff --git a/backend/ee/integrations/secrets/dynamic/rest/views.py b/backend/ee/integrations/secrets/dynamic/rest/views.py index c3ba41760..7c16b699f 100644 --- a/backend/ee/integrations/secrets/dynamic/rest/views.py +++ b/backend/ee/integrations/secrets/dynamic/rest/views.py @@ -82,6 +82,7 @@ def initial(self, request, *args, **kwargs): organisation, True, request.auth.get("service_account") is not None, + app=env.app, ): raise PermissionDenied( f"You don't have permission to {action} secrets in this environment." @@ -232,6 +233,7 @@ def _assert_can_act_on_lease(self, request, lease: DynamicSecretLease, action: s and lease_holder.id == getattr(account, "id", None) ): return + env = request.auth["environment"] if not user_has_permission( account, action, @@ -239,6 +241,7 @@ def _assert_can_act_on_lease(self, request, lease: DynamicSecretLease, action: s organisation, True, request.auth.get("service_account") is not None, + app=env.app, ): raise PermissionDenied( f"You don't have permission to {action} leases for other accounts." @@ -252,6 +255,7 @@ def get(self, request, *args, **kwargs): account, organisation = self._get_account_and_org(request) + env = request.auth["environment"] if not user_has_permission( account, "read", @@ -259,6 +263,7 @@ def get(self, request, *args, **kwargs): organisation, True, request.auth.get("service_account") is not None, + app=env.app, ): raise PermissionDenied( f"You don't have permission to read secrets in this environment." @@ -279,6 +284,7 @@ def get(self, request, *args, **kwargs): organisation, True, request.auth.get("service_account") is not None, + app=env.app, ): if request.auth["org_member"] is not None: leases_filter["organisation_member"] = request.auth["org_member"] diff --git a/backend/pytest.ini b/backend/pytest.ini index f912902b3..91a5cca2a 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,2 +1,2 @@ [pytest] -python_files = tests.py test_*.py *_tests.py \ No newline at end of file +python_files = tests.py test_*.py *_tests.py diff --git a/backend/tests/api/mutations/test_update_member_env_scope.py b/backend/tests/api/mutations/test_update_member_env_scope.py new file mode 100644 index 000000000..6c5484b7a --- /dev/null +++ b/backend/tests/api/mutations/test_update_member_env_scope.py @@ -0,0 +1,126 @@ +"""Unit tests for UpdateMemberEnvScopeMutation grant handling +(F5 of QA batch 1). + +A single EnvironmentKey row can carry multiple EnvironmentKeyGrant +entries — `provision_team_environment_keys` re-uses an existing key +rather than creating a duplicate, so a member can simultaneously hold +an `individual` and a `team` grant on the same key. The previous +implementation deleted ALL grants on the old keys when updating direct +scope, then soft-deleted the keys, silently revoking team-provided +access. The fix: + +1. Delete only `grant_type='individual'` grants on the existing keys. +2. Soft-delete only the keys with no remaining grants. +3. For envs in the new direct scope where the existing key is + preserved (because a team grant survives), attach a new individual + grant to the existing row instead of trying to create a duplicate + row (which would violate the (env, user|sa) uniqueness constraint). +""" + +from unittest.mock import MagicMock, patch + + +def _make_info(user): + info = MagicMock() + info.context.user = user + return info + + +def _make_env_key_input(env_id, user_id="u1", wrapped_seed="ws", wrapped_salt="wsl", + identity_key="ik"): + k = MagicMock() + k.env_id = env_id + k.user_id = user_id + k.wrapped_seed = wrapped_seed + k.wrapped_salt = wrapped_salt + k.identity_key = identity_key + return k + + +@patch("backend.graphene.mutations.environment.timezone") +@patch("backend.graphene.mutations.environment.transaction") +@patch("backend.graphene.mutations.environment.EnvironmentKeyGrant") +@patch("backend.graphene.mutations.environment.EnvironmentKey") +@patch("backend.graphene.mutations.environment.OrganisationMember") +@patch("backend.graphene.mutations.environment.App") +@patch("backend.graphene.mutations.environment.user_can_access_app", return_value=True) +@patch("backend.graphene.mutations.environment.user_has_permission", return_value=True) +def test_team_granted_keys_survive_direct_scope_edit( + _mock_perm, _mock_access, MockApp, MockOM, MockEnvKey, MockGrant, mock_tx, mock_tz, +): + """A key carrying both an individual grant and a team grant for the + same member must keep the team grant (and itself) when the direct + scope is reduced. Previously this would delete all grants and + soft-delete the key, silently revoking team access.""" + from backend.graphene.mutations.environment import UpdateMemberEnvScopeMutation + from backend.graphene.types import MemberType + + # Setup: app + member context. + app = MagicMock() + MockApp.objects.get.return_value = app + member = MagicMock() + MockOM.objects.get.return_value = member + app.members.all.return_value = [member] + + # Two existing keys for this member: + # - K1 on env-A: individual + team grants + # - K2 on env-B: individual grant only + k1 = MagicMock(id="k1", environment_id="env-A") + k2 = MagicMock(id="k2", environment_id="env-B") + MockEnvKey.objects.filter.return_value = [k1, k2] + + # Grant queries: + # First call (delete individual grants) → no return value needed. + # Second call (find keys with remaining grants) → returns + # environment_key_id values: only k1 still has a (team) grant. + # Third call (soft-delete keys with no remaining grants) → updates + # k2 only. + delete_mock = MagicMock() + remaining_qs = MagicMock() + remaining_qs.values_list.return_value = ["k1"] + MockGrant.objects.filter.side_effect = [delete_mock, remaining_qs] + + soft_delete_qs = MagicMock() + MockEnvKey.objects.filter.return_value = soft_delete_qs + + # The mutation reads `EnvironmentKey.objects.filter(...)` twice: + # (a) first to collect old keys (returns iterable), + # (b) second to soft-delete the orphan subset. + # Use side_effect to return different values per call. + MockEnvKey.objects.filter.side_effect = [ + # (a) old keys list + MagicMock(__iter__=MagicMock(return_value=iter([k1, k2]))), + # (b) soft-delete queryset for non-preserved keys + soft_delete_qs, + ] + + user = MagicMock() + user.userId = "u1" + + # New direct scope: only env-A (env-B is dropped). + UpdateMemberEnvScopeMutation.mutate( + None, + _make_info(user), + member_id="m1", + app_id="app-1", + env_keys=[_make_env_key_input("env-A")], + member_type=MemberType.USER, + ) + + # Assertions: + # 1. Individual grants were deleted on the old keys. + delete_mock.delete.assert_called_once() + # 2. Soft-delete was called on keys WITHOUT remaining grants — the + # queryset filter should have included k2 but excluded k1 + # (k1 still has a team grant). + soft_delete_qs.update.assert_called_once() + # 3. For env-A (the preserved key with a team grant), the mutation + # used get_or_create on the EXISTING row rather than creating a + # duplicate EnvironmentKey row. + MockGrant.objects.get_or_create.assert_called_once() + create_call = MockGrant.objects.get_or_create.call_args + assert create_call.kwargs["environment_key"] is k1 + assert create_call.kwargs["grant_type"] == "individual" + # 4. No new EnvironmentKey row was created for env-A — the mutation + # re-used the preserved row to avoid a uniqueness conflict. + MockEnvKey.objects.create.assert_not_called() diff --git a/backend/tests/api/mutations/test_update_member_role.py b/backend/tests/api/mutations/test_update_member_role.py new file mode 100644 index 000000000..ce783c703 --- /dev/null +++ b/backend/tests/api/mutations/test_update_member_role.py @@ -0,0 +1,160 @@ +"""Pending-member role-change guard for UpdateOrganisationMemberRole. + +A SCIM-provisioned user before first login has no identity_key. If +they're given a role that enrols them as a service account handler +(global-access or ServiceAccountTokens.create), the next SA creation +tries to wrap a keyring for that empty identity_key and breaks. The +mutation must reject those role changes until the user completes their +key ceremony. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +def _info(user): + info = MagicMock() + info.context.user = user + return info + + +def _role(name, permissions=None, *, is_default=True, global_access=False): + role = MagicMock() + role.name = name + role.is_default = is_default + role.permissions = permissions or {"global_access": global_access} + return role + + +@patch("backend.graphene.mutations.organisation.Role") +@patch("backend.graphene.mutations.organisation.OrganisationMember") +@patch("backend.graphene.mutations.organisation.user_has_permission", return_value=True) +def test_pending_member_blocked_from_global_access_role( + _mock_perm, MockOM, MockRole +): + from backend.graphene.mutations.organisation import UpdateOrganisationMemberRole + from graphql import GraphQLError + + pending = MagicMock(identity_key="", role=_role("Developer")) + pending.user = MagicMock() + caller_membership = MagicMock(role=_role("Owner")) + MockOM.objects.get.side_effect = [pending, caller_membership] + + admin_role = _role("Admin") + MockRole.objects.get.return_value = admin_role + + with patch( + "backend.graphene.mutations.organisation.role_has_global_access", + side_effect=lambda r: r is admin_role, + ), patch( + "backend.graphene.mutations.organisation.role_has_permission", + return_value=False, + ): + with pytest.raises(GraphQLError, match="hasn't completed account setup"): + UpdateOrganisationMemberRole.mutate( + None, _info(MagicMock()), member_id="m1", role_id="r1" + ) + + pending.save.assert_not_called() + + +@patch("backend.graphene.mutations.organisation.Role") +@patch("backend.graphene.mutations.organisation.OrganisationMember") +@patch("backend.graphene.mutations.organisation.user_has_permission", return_value=True) +def test_pending_member_blocked_from_sa_token_create_role( + _mock_perm, MockOM, MockRole +): + from backend.graphene.mutations.organisation import UpdateOrganisationMemberRole + from graphql import GraphQLError + + pending = MagicMock(identity_key="", role=_role("Developer")) + pending.user = MagicMock() + caller_membership = MagicMock(role=_role("Owner")) + MockOM.objects.get.side_effect = [pending, caller_membership] + + manager_role = _role("Manager") + MockRole.objects.get.return_value = manager_role + + with patch( + "backend.graphene.mutations.organisation.role_has_global_access", + return_value=False, + ), patch( + "backend.graphene.mutations.organisation.role_has_permission", + side_effect=lambda r, action, resource: ( + r is manager_role + and action == "create" + and resource == "ServiceAccountTokens" + ), + ): + with pytest.raises(GraphQLError, match="hasn't completed account setup"): + UpdateOrganisationMemberRole.mutate( + None, _info(MagicMock()), member_id="m1", role_id="r1" + ) + + pending.save.assert_not_called() + + +@patch("backend.graphene.mutations.organisation.Role") +@patch("backend.graphene.mutations.organisation.OrganisationMember") +@patch("backend.graphene.mutations.organisation.user_has_permission", return_value=True) +def test_pending_member_can_take_safe_role(_mock_perm, MockOM, MockRole): + """Regression: pending members can still be assigned a safe role + (e.g. Developer → custom-no-SA-tokens) before they log in.""" + from backend.graphene.mutations.organisation import UpdateOrganisationMemberRole + + pending = MagicMock(identity_key="", role=_role("Developer")) + pending.user = MagicMock() + caller_membership = MagicMock(role=_role("Owner")) + MockOM.objects.get.side_effect = [pending, caller_membership] + + safe_role = _role("Custom") + MockRole.objects.get.return_value = safe_role + + with patch( + "backend.graphene.mutations.organisation.role_has_global_access", + return_value=False, + ), patch( + "backend.graphene.mutations.organisation.role_has_permission", + return_value=False, + ): + UpdateOrganisationMemberRole.mutate( + None, _info(MagicMock()), member_id="m1", role_id="r1" + ) + + assert pending.role is safe_role + pending.save.assert_called_once() + + +@patch("backend.graphene.mutations.organisation.Role") +@patch("backend.graphene.mutations.organisation.OrganisationMember") +@patch("backend.graphene.mutations.organisation.user_has_permission", return_value=True) +def test_active_member_can_take_global_access_role( + _mock_perm, MockOM, MockRole +): + """Members who've completed their key ceremony can be promoted to + global-access roles like normal — the guard only fires on empty + identity_key.""" + from backend.graphene.mutations.organisation import UpdateOrganisationMemberRole + + active = MagicMock(identity_key="not-empty", role=_role("Developer")) + active.user = MagicMock() + caller_membership = MagicMock(role=_role("Owner")) + MockOM.objects.get.side_effect = [active, caller_membership] + + admin_role = _role("Admin") + MockRole.objects.get.return_value = admin_role + + with patch( + "backend.graphene.mutations.organisation.role_has_global_access", + side_effect=lambda r: r is admin_role, + ), patch( + "backend.graphene.mutations.organisation.role_has_permission", + return_value=False, + ): + UpdateOrganisationMemberRole.mutate( + None, _info(MagicMock()), member_id="m1", role_id="r1" + ) + + assert active.role is admin_role + active.save.assert_called_once() diff --git a/backend/tests/api/mutations/test_update_service_account_handlers.py b/backend/tests/api/mutations/test_update_service_account_handlers.py new file mode 100644 index 000000000..5086a5dde --- /dev/null +++ b/backend/tests/api/mutations/test_update_service_account_handlers.py @@ -0,0 +1,127 @@ +"""Unit tests for UpdateServiceAccountHandlersMutation (F4 of QA batch 1). + +A non-global user with org-level `ServiceAccounts.update` could +previously pass team-owned SA ids in `handlers` — the bulk delete at +the top of the mutation wiped handlers before any per-SA visibility +check ran. The fix pre-flights `_check_sa_permission` over every +submitted SA before any deletion happens, so a single inaccessible SA +in the payload aborts the whole mutation with no side effects. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from graphql import GraphQLError + + +def _make_info(user): + info = MagicMock() + info.context.user = user + return info + + +def _make_handler_input(sa_id, member_id="m1"): + h = MagicMock() + h.service_account_id = sa_id + h.member_id = member_id + h.wrapped_keyring = "wk" + h.wrapped_recovery = "wr" + return h + + +@patch("backend.graphene.mutations.service_accounts.ServiceAccountHandler") +@patch("backend.graphene.mutations.service_accounts.ServiceAccount") +@patch("backend.graphene.mutations.service_accounts._check_sa_permission") +@patch("backend.graphene.mutations.service_accounts.user_has_permission", return_value=True) +@patch("backend.graphene.mutations.service_accounts.user_is_org_member", return_value=True) +@patch("backend.graphene.mutations.service_accounts.Organisation") +def test_pre_flight_check_blocks_inaccessible_team_sa( + MockOrg, + _mock_org_member, + _mock_perm, + mock_check_sa, + MockSA, + MockHandler, +): + """An inaccessible SA in the payload aborts the whole mutation + before any handler row is touched.""" + from backend.graphene.mutations.service_accounts import ( + UpdateServiceAccountHandlersMutation, + ) + + org = MagicMock() + MockOrg.objects.get.return_value = org + + accessible_sa = MagicMock(id="sa-ok") + inaccessible_sa = MagicMock(id="sa-blocked") + MockSA.objects.filter.return_value.select_related.return_value = [ + accessible_sa, inaccessible_sa, + ] + + # _check_sa_permission raises on the second SA (the team-owned one + # the caller can't access). + mock_check_sa.side_effect = [None, GraphQLError("denied")] + + user = MagicMock() + user.userId = "u1" + + with pytest.raises(GraphQLError): + UpdateServiceAccountHandlersMutation.mutate( + None, + _make_info(user), + organisation_id="org-1", + handlers=[ + _make_handler_input("sa-ok"), + _make_handler_input("sa-blocked"), + ], + ) + + # No bulk delete and no handler creation should have happened. + MockHandler.objects.filter.return_value.delete.assert_not_called() + MockHandler.objects.create.assert_not_called() + + +@patch("backend.graphene.mutations.service_accounts.ServiceAccountHandler") +@patch("backend.graphene.mutations.service_accounts.ServiceAccount") +@patch("backend.graphene.mutations.service_accounts._check_sa_permission", return_value=None) +@patch("backend.graphene.mutations.service_accounts.user_has_permission", return_value=True) +@patch("backend.graphene.mutations.service_accounts.user_is_org_member", return_value=True) +@patch("backend.graphene.mutations.service_accounts.Organisation") +def test_pre_flight_passes_when_all_sas_accessible( + MockOrg, + _mock_org_member, + _mock_perm, + _mock_check_sa, + MockSA, + MockHandler, +): + """Regression: when every SA in the payload passes the per-SA + permission check, the mutation proceeds to delete handlers and + create the new ones.""" + from backend.graphene.mutations.service_accounts import ( + UpdateServiceAccountHandlersMutation, + ) + + org = MagicMock() + MockOrg.objects.get.return_value = org + + sa = MagicMock(id="sa-1") + MockSA.objects.filter.return_value.select_related.return_value = [sa] + # `ServiceAccount.objects.get(...)` for the per-handler create loop + MockSA.objects.get.return_value = sa + # No existing handler for this (sa, member) pair. + MockHandler.objects.filter.return_value.exists.return_value = False + + user = MagicMock() + user.userId = "u1" + + UpdateServiceAccountHandlersMutation.mutate( + None, + _make_info(user), + organisation_id="org-1", + handlers=[_make_handler_input("sa-1")], + ) + + # Bulk delete fired; new handler created. + MockHandler.objects.filter.return_value.delete.assert_called() + MockHandler.objects.create.assert_called_once() diff --git a/backend/tests/api/test_service_account_token_exposure.py b/backend/tests/api/test_service_account_token_exposure.py new file mode 100644 index 000000000..4dea3417a --- /dev/null +++ b/backend/tests/api/test_service_account_token_exposure.py @@ -0,0 +1,156 @@ +"""Tests for the gating on ServiceAccountType nested resolvers. + +A reviewer found that any user with `Teams.read` could harvest team- +owned service-account tokens (raw token + wrapped_key_share + +identity_key) cross-team via: + + teams.members.serviceAccount.tokens + +The fix gates `ServiceAccountType.resolve_tokens` and `resolve_handlers` +through `_check_sa_permission`, returning [] when the caller can't +access the SA. These tests pin that behaviour at the resolver level, +mocking the underlying permission check so we exercise the resolver's +contract (returns [] vs returns rows) without standing up a full DB. +""" + +from unittest.mock import MagicMock, patch + +from graphql import GraphQLError + + +def _info(user): + info = MagicMock() + info.context.user = user + return info + + +def _make_sa(team=None): + sa = MagicMock() + sa.team = team + sa.organisation = MagicMock() + return sa + + +@patch("backend.graphene.types.ServiceAccountToken") +@patch("api.utils.access.permissions._check_sa_permission") +def test_resolve_tokens_returns_empty_when_caller_lacks_access( + mock_check, mock_token_cls +): + """Manager without team access → tokens resolver returns []. The + main P0 vector — caller can reach the SA through teams.members but + must not see the underlying token rows.""" + from backend.graphene.types import ServiceAccountType + + mock_check.side_effect = GraphQLError("denied") + + sa = _make_sa(team=MagicMock(id="team-1")) + user = MagicMock() + user.userId = "u1" + + result = ServiceAccountType.resolve_tokens(sa, _info(user)) + + assert result == [] + mock_token_cls.objects.filter.assert_not_called() + + +@patch("backend.graphene.types.ServiceAccountToken") +@patch("api.utils.access.permissions._check_sa_permission") +def test_resolve_tokens_returns_rows_when_authorised( + mock_check, mock_token_cls +): + """Owner / team-member with the right permission → tokens are + returned. Mocked check passes through (no exception).""" + from backend.graphene.types import ServiceAccountType + + mock_check.return_value = None # no exception → permitted + + sa = _make_sa(team=MagicMock(id="team-1")) + user = MagicMock() + user.userId = "u1" + + expected_qs = MagicMock() + mock_token_cls.objects.filter.return_value = expected_qs + + result = ServiceAccountType.resolve_tokens(sa, _info(user)) + + mock_token_cls.objects.filter.assert_called_once_with( + service_account=sa, deleted_at=None + ) + assert result is expected_qs + # Confirm we actually invoked the gate with the right resource — + # this is the difference between a token-leak and a metadata leak. + args, _kwargs = mock_check.call_args + assert args[2] == "read" + assert args[3] == "ServiceAccountTokens" + + +@patch("backend.graphene.types.ServiceAccountHandler") +@patch("api.utils.access.permissions._check_sa_permission") +def test_resolve_handlers_returns_empty_when_caller_lacks_access( + mock_check, mock_handler_cls +): + """Same shape leak via `handlers` (exposes wrapped_keyring / + wrapped_recovery via fields=__all__). Gate must reject the same + way.""" + from backend.graphene.types import ServiceAccountType + + mock_check.side_effect = GraphQLError("denied") + + sa = _make_sa(team=MagicMock(id="team-1")) + user = MagicMock() + user.userId = "u1" + + result = ServiceAccountType.resolve_handlers(sa, _info(user)) + + assert result == [] + mock_handler_cls.objects.filter.assert_not_called() + + +@patch("backend.graphene.types.ServiceAccountHandler") +@patch("api.utils.access.permissions._check_sa_permission") +def test_resolve_handlers_uses_serviceaccounts_resource( + mock_check, mock_handler_cls +): + """Handlers gate on `ServiceAccounts.read` (managing the SA itself), + not `ServiceAccountTokens.read`. Pins the resource string so a + future refactor can't silently weaken the check.""" + from backend.graphene.types import ServiceAccountType + + mock_check.return_value = None + + sa = _make_sa(team=MagicMock(id="team-1")) + user = MagicMock() + user.userId = "u1" + + ServiceAccountType.resolve_handlers(sa, _info(user)) + + args, _kwargs = mock_check.call_args + assert args[2] == "read" + assert args[3] == "ServiceAccounts" + + +@patch("backend.graphene.types.ServiceAccountToken") +def test_resolve_tokens_uses_real_permission_path_for_org_level_sa( + mock_token_cls, +): + """End-to-end-ish: for an org-level (non-team) SA, the gate falls + through to `user_has_permission` on the org. A user with no + `ServiceAccountTokens.read` permission gets []. + + Exercises the real `_check_sa_permission` (no patch) so we confirm + the relocation to api.utils.access.permissions didn't break + behaviour for the org-level path.""" + from backend.graphene.types import ServiceAccountType + + sa = _make_sa(team=None) + sa.organisation = MagicMock() + user = MagicMock() + user.userId = "u1" + + with patch( + "api.utils.access.permissions.user_has_permission", return_value=False + ): + result = ServiceAccountType.resolve_tokens(sa, _info(user)) + + assert result == [] + mock_token_cls.objects.filter.assert_not_called() diff --git a/backend/tests/api/test_social_adapter.py b/backend/tests/api/test_social_adapter.py new file mode 100644 index 000000000..52245cbf5 --- /dev/null +++ b/backend/tests/api/test_social_adapter.py @@ -0,0 +1,91 @@ +"""Unit tests for AutoLinkSocialAccountAdapter (F2 of QA batch 1). + +The adapter currently isn't invoked in the product SSO flow (which +bypasses allauth's `complete_social_login`), but it is still registered +globally via `SOCIALACCOUNT_ADAPTER`. Anything that does flow through +allauth — Django admin OAuth, future allauth-based code paths, +third-party deps — would invoke it. The auto-link must only fire when +the IdP's `email_verified` claim isn't explicitly False. +""" + +from unittest.mock import MagicMock, patch + + +def _make_sociallogin(email, extra_data, is_existing=False): + sociallogin = MagicMock() + sociallogin.is_existing = is_existing + sociallogin.user = MagicMock(email=email) + sociallogin.account = MagicMock( + provider="okta-oidc", + uid="idp-uid-123", + extra_data=extra_data, + ) + return sociallogin + + +@patch("api.authentication.adapters.social.SocialAccount") +@patch("api.authentication.adapters.social.User") +def test_skips_link_when_email_explicitly_unverified(MockUser, MockSocialAccount): + """`email_verified=False` is the only blocker — a malicious or + misconfigured IdP claiming another user's email must not be silently + linked to that account.""" + from api.authentication.adapters.social import AutoLinkSocialAccountAdapter + + adapter = AutoLinkSocialAccountAdapter() + sociallogin = _make_sociallogin( + email="victim@example.com", + extra_data={"email_verified": False}, + ) + + adapter.pre_social_login(request=MagicMock(), sociallogin=sociallogin) + + # Neither the lookup nor the link should have happened. + MockUser.objects.get.assert_not_called() + MockSocialAccount.objects.get_or_create.assert_not_called() + + +@patch("api.authentication.adapters.social.SocialAccount") +@patch("api.authentication.adapters.social.User") +def test_links_when_email_verified_true(MockUser, MockSocialAccount): + existing = MagicMock() + MockUser.objects.get.return_value = existing + sa = MagicMock() + MockSocialAccount.objects.get_or_create.return_value = (sa, True) + + from api.authentication.adapters.social import AutoLinkSocialAccountAdapter + + adapter = AutoLinkSocialAccountAdapter() + sociallogin = _make_sociallogin( + email="alice@example.com", + extra_data={"email_verified": True}, + ) + + adapter.pre_social_login(request=MagicMock(), sociallogin=sociallogin) + + MockUser.objects.get.assert_called_once_with(email="alice@example.com") + MockSocialAccount.objects.get_or_create.assert_called_once() + + +@patch("api.authentication.adapters.social.SocialAccount") +@patch("api.authentication.adapters.social.User") +def test_links_when_email_verified_absent(MockUser, MockSocialAccount): + """Some IdPs (Microsoft work accounts, older OIDC) don't emit the + claim. Those flow through — the adapter trusts the IdP at the + registration level. Only an explicit False is a blocker.""" + existing = MagicMock() + MockUser.objects.get.return_value = existing + sa = MagicMock() + MockSocialAccount.objects.get_or_create.return_value = (sa, True) + + from api.authentication.adapters.social import AutoLinkSocialAccountAdapter + + adapter = AutoLinkSocialAccountAdapter() + sociallogin = _make_sociallogin( + email="alice@example.com", + extra_data={}, # no email_verified key at all + ) + + adapter.pre_social_login(request=MagicMock(), sociallogin=sociallogin) + + MockUser.objects.get.assert_called_once() + MockSocialAccount.objects.get_or_create.assert_called_once() diff --git a/backend/tests/ee/__init__.py b/backend/tests/ee/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/ee/authentication/__init__.py b/backend/tests/ee/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/ee/authentication/scim/__init__.py b/backend/tests/ee/authentication/scim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/ee/authentication/scim/conftest.py b/backend/tests/ee/authentication/scim/conftest.py new file mode 100644 index 000000000..d1d7e881d --- /dev/null +++ b/backend/tests/ee/authentication/scim/conftest.py @@ -0,0 +1,225 @@ +import hashlib +import uuid +from datetime import datetime, timezone as dt_tz +from unittest.mock import MagicMock, Mock, patch + +import pytest +from rest_framework.test import APIClient + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SCIM_CONTENT_TYPE = "application/scim+json" + +TOKEN_RAW = "ph_scim:v1:testpfx:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +TOKEN_HASH = hashlib.sha256(TOKEN_RAW.encode()).hexdigest() +TOKEN_PREFIX = "testpfx" + + +def scim_auth_header(token=TOKEN_RAW): + return f"Bearer {token}" + + +# --------------------------------------------------------------------------- +# Payload helpers (pure data — no DB) +# --------------------------------------------------------------------------- + + +def make_scim_user_payload( + username, + external_id, + display_name=None, + given_name=None, + family_name=None, + active=True, +): + """Build a SCIM User resource payload matching what IdPs send.""" + given = given_name or username.split("@")[0].title() + family = family_name or "Test" + display = display_name or f"{given} {family}" + + return { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId": external_id, + "userName": username, + "displayName": display, + "name": { + "givenName": given, + "familyName": family, + "formatted": display, + }, + "emails": [{"value": username, "type": "work", "primary": True}], + "active": active, + } + + +def make_scim_group_payload(display_name, external_id, members=None, description=None): + """Build a SCIM Group resource payload.""" + payload = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "externalId": external_id, + "displayName": display_name, + "members": members or [], + } + if description: + payload["description"] = description + return payload + + +def make_patch_op(operations): + """Build a SCIM PatchOp payload.""" + return { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": operations, + } + + +# --------------------------------------------------------------------------- +# Mock factories +# --------------------------------------------------------------------------- + + +def make_mock_organisation(**overrides): + """Create a mock Organisation with sensible defaults.""" + org = MagicMock() + org.id = overrides.get("id", str(uuid.uuid4())) + org.name = overrides.get("name", "TestOrg-SCIM") + org.plan = overrides.get("plan", "EN") + org.scim_enabled = overrides.get("scim_enabled", True) + org.identity_key = overrides.get("identity_key", "test-org-identity-key") + return org + + +def make_mock_scim_token(organisation=None, **overrides): + """Create a mock SCIMToken.""" + token = MagicMock() + token.id = overrides.get("id", str(uuid.uuid4())) + token.organisation = organisation or make_mock_organisation() + token.name = overrides.get("name", "Test IdP") + token.token_hash = overrides.get("token_hash", TOKEN_HASH) + token.token_prefix = overrides.get("token_prefix", TOKEN_PREFIX) + token.is_active = overrides.get("is_active", True) + token.expires_at = overrides.get("expires_at", None) + token.deleted_at = overrides.get("deleted_at", None) + token.last_used_at = overrides.get("last_used_at", None) + token.created_by = overrides.get("created_by", MagicMock()) + return token + + +def make_mock_user(**overrides): + """Create a mock CustomUser.""" + user = MagicMock() + user.userId = overrides.get("userId", str(uuid.uuid4())) + user.username = overrides.get("username", "user@example.com") + user.email = overrides.get("email", user.username) + user.has_usable_password = MagicMock(return_value=False) + return user + + +def make_mock_org_member(user=None, organisation=None, **overrides): + """Create a mock OrganisationMember.""" + member = MagicMock() + member.id = overrides.get("id", str(uuid.uuid4())) + member.user = user or make_mock_user() + member.organisation = organisation or make_mock_organisation() + member.role = overrides.get("role", MagicMock(name="Developer")) + member.identity_key = overrides.get("identity_key", "") + member.wrapped_keyring = overrides.get("wrapped_keyring", "") + member.wrapped_recovery = overrides.get("wrapped_recovery", "") + member.deleted_at = overrides.get("deleted_at", None) + return member + + +def make_mock_scim_user(organisation=None, user=None, org_member=None, **overrides): + """Create a mock SCIMUser.""" + org = organisation or make_mock_organisation() + scim_user = MagicMock() + scim_user.id = overrides.get("id", str(uuid.uuid4())) + scim_user.external_id = overrides.get("external_id", "ext-" + str(uuid.uuid4())[:8]) + scim_user.organisation = org + scim_user.user = user or make_mock_user(email=overrides.get("email", "user@example.com")) + scim_user.org_member = org_member or make_mock_org_member(user=scim_user.user, organisation=org) + scim_user.email = overrides.get("email", scim_user.user.email) + scim_user.display_name = overrides.get("display_name", "Test User") + scim_user.active = overrides.get("active", True) + scim_user.scim_data = overrides.get( + "scim_data", + make_scim_user_payload(scim_user.email, scim_user.external_id), + ) + scim_user.created_at = overrides.get("created_at", datetime(2025, 1, 1, tzinfo=dt_tz.utc)) + scim_user.updated_at = overrides.get("updated_at", datetime(2025, 1, 1, tzinfo=dt_tz.utc)) + return scim_user + + +def make_mock_team(organisation=None, **overrides): + """Create a mock Team.""" + team = MagicMock() + team.id = overrides.get("id", str(uuid.uuid4())) + team.name = overrides.get("name", "TestTeam") + team.organisation = organisation or make_mock_organisation() + team.is_scim_managed = overrides.get("is_scim_managed", True) + team.description = overrides.get("description", None) + team.deleted_at = overrides.get("deleted_at", None) + return team + + +def make_mock_scim_group(organisation=None, team=None, **overrides): + """Create a mock SCIMGroup.""" + org = organisation or make_mock_organisation() + scim_group = MagicMock() + scim_group.id = overrides.get("id", str(uuid.uuid4())) + scim_group.external_id = overrides.get("external_id", "grp-" + str(uuid.uuid4())[:8]) + scim_group.organisation = org + scim_group.team = team or make_mock_team(organisation=org, name=overrides.get("display_name", "TestGroup")) + scim_group.display_name = overrides.get("display_name", "TestGroup") + scim_group.scim_data = overrides.get( + "scim_data", + make_scim_group_payload(scim_group.display_name, scim_group.external_id), + ) + scim_group.created_at = overrides.get("created_at", datetime(2025, 1, 1, tzinfo=dt_tz.utc)) + scim_group.updated_at = overrides.get("updated_at", datetime(2025, 1, 1, tzinfo=dt_tz.utc)) + return scim_group + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_organisation(): + return make_mock_organisation() + + +@pytest.fixture +def mock_scim_token(mock_organisation): + return make_mock_scim_token(organisation=mock_organisation) + + +@pytest.fixture +def mock_auth(mock_organisation, mock_scim_token): + """Patch SCIMTokenAuthentication.authenticate so that all views see a + valid SCIM service user without touching the DB.""" + from ee.authentication.scim.auth import SCIMServiceUser + + service_user = SCIMServiceUser(mock_scim_token) + auth_info = { + "scim_token": mock_scim_token, + "organisation": mock_organisation, + } + + with patch( + "ee.authentication.scim.auth.SCIMTokenAuthentication.authenticate", + return_value=(service_user, auth_info), + ) as m: + yield m + + +@pytest.fixture +def scim_client(mock_auth): + """DRF APIClient that passes SCIM Bearer auth (mocked).""" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=scim_auth_header()) + return client diff --git a/backend/tests/ee/authentication/scim/test_auth.py b/backend/tests/ee/authentication/scim/test_auth.py new file mode 100644 index 000000000..dedaa6a4b --- /dev/null +++ b/backend/tests/ee/authentication/scim/test_auth.py @@ -0,0 +1,209 @@ +"""Tests for SCIMTokenAuthentication — fully mocked, no database.""" + +import hashlib +from datetime import timedelta +from unittest.mock import MagicMock, patch + +import pytest +from django.utils import timezone +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.test import APIRequestFactory + +from ee.authentication.scim.auth import SCIMServiceUser, SCIMTokenAuthentication + +from .conftest import TOKEN_HASH, TOKEN_RAW, make_mock_organisation, make_mock_scim_token + +factory = APIRequestFactory() + + +def _make_request(token=TOKEN_RAW): + return factory.get( + "/scim/v2/Users", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + +# --------------------------------------------------------------------------- +# SCIMTokenAuthentication +# --------------------------------------------------------------------------- + + +class TestSCIMTokenAuthentication: + + def _make_token(self, **overrides): + org = overrides.pop("organisation", make_mock_organisation()) + return make_mock_scim_token(organisation=org, **overrides) + + # -- valid token -- + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_valid_token(self, MockSCIMToken, mock_can_use): + token = self._make_token() + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + user, auth_info = auth.authenticate(_make_request()) + + assert isinstance(user, SCIMServiceUser) + assert user.is_authenticated + assert auth_info["organisation"] is token.organisation + assert auth_info["scim_token"] is token + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_updates_last_used_at(self, MockSCIMToken, mock_can_use): + token = self._make_token() + assert token.last_used_at is None + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + auth.authenticate(_make_request()) + + token.save.assert_called_once_with(update_fields=["last_used_at"]) + assert token.last_used_at is not None + + # -- header parsing -- + + def test_no_auth_header_returns_none(self): + auth = SCIMTokenAuthentication() + request = factory.get("/scim/v2/Users") + result = auth.authenticate(request) + assert result is None + + def test_non_bearer_header_returns_none(self): + auth = SCIMTokenAuthentication() + request = factory.get( + "/scim/v2/Users", + HTTP_AUTHORIZATION="Basic dXNlcjpwYXNz", + ) + result = auth.authenticate(request) + assert result is None + + def test_non_scim_bearer_token_returns_none(self): + """Phase tokens (pss_service:v2:...) should be ignored.""" + auth = SCIMTokenAuthentication() + request = _make_request(token="pss_service:v2:prefix:body") + result = auth.authenticate(request) + assert result is None + + # -- invalid hash -- + + @patch("ee.authentication.scim.auth.SCIMToken") + def test_invalid_token_hash_raises(self, MockSCIMToken): + MockSCIMToken.DoesNotExist = Exception + MockSCIMToken.objects.select_related.return_value.get.side_effect = Exception("not found") + + auth = SCIMTokenAuthentication() + with pytest.raises(AuthenticationFailed, match="Invalid SCIM token"): + auth.authenticate(_make_request(token="ph_scim:v1:fake:invalid_token_value")) + + # -- expired -- + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_expired_token_raises(self, MockSCIMToken, mock_can_use): + token = self._make_token(expires_at=timezone.now() - timedelta(hours=1)) + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + with pytest.raises(AuthenticationFailed, match="expired"): + auth.authenticate(_make_request()) + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_non_expired_token_works(self, MockSCIMToken, mock_can_use): + token = self._make_token(expires_at=timezone.now() + timedelta(days=30)) + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + user, _ = auth.authenticate(_make_request()) + assert user.is_authenticated + + # -- scim disabled -- + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_scim_disabled_on_org_raises(self, MockSCIMToken, mock_can_use): + org = make_mock_organisation(scim_enabled=False) + token = self._make_token(organisation=org) + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + with pytest.raises(AuthenticationFailed, match="not enabled"): + auth.authenticate(_make_request()) + + # -- token inactive -- + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_token_is_inactive_raises(self, MockSCIMToken, mock_can_use): + token = self._make_token(is_active=False) + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + with pytest.raises(AuthenticationFailed, match="disabled"): + auth.authenticate(_make_request()) + + # -- soft-deleted token -- + + @patch("ee.authentication.scim.auth.SCIMToken") + def test_soft_deleted_token_raises(self, MockSCIMToken): + """Soft-deleted tokens are filtered by the query (deleted_at__isnull=True), + so they raise DoesNotExist.""" + MockSCIMToken.DoesNotExist = Exception + MockSCIMToken.objects.select_related.return_value.get.side_effect = Exception("not found") + + auth = SCIMTokenAuthentication() + with pytest.raises(AuthenticationFailed, match="Invalid SCIM token"): + auth.authenticate(_make_request()) + + # -- plan checks -- + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=False) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_non_enterprise_plan_without_license_raises(self, MockSCIMToken, mock_can_use): + org = make_mock_organisation(plan="FR") + token = self._make_token(organisation=org) + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + with pytest.raises(AuthenticationFailed, match="Enterprise plan"): + auth.authenticate(_make_request()) + + @patch("ee.authentication.scim.auth.can_use_scim", return_value=True) + @patch("ee.authentication.scim.auth.SCIMToken") + def test_non_enterprise_plan_with_license_works(self, MockSCIMToken, mock_can_use): + """An activated license should bypass the plan check via can_use_scim.""" + org = make_mock_organisation(plan="FR") + token = self._make_token(organisation=org) + MockSCIMToken.objects.select_related.return_value.get.return_value = token + MockSCIMToken.DoesNotExist = Exception + + auth = SCIMTokenAuthentication() + user, _ = auth.authenticate(_make_request()) + assert user.is_authenticated + + +# --------------------------------------------------------------------------- +# SCIMServiceUser +# --------------------------------------------------------------------------- + + +class TestSCIMServiceUser: + + def test_service_user_properties(self): + token = make_mock_scim_token() + user = SCIMServiceUser(token) + assert user.is_authenticated is True + assert user.is_active is True + assert user.id == token.id + assert user.organisation == token.organisation + assert user.scim_token == token diff --git a/backend/tests/ee/authentication/scim/test_filters.py b/backend/tests/ee/authentication/scim/test_filters.py new file mode 100644 index 000000000..75fa06bc8 --- /dev/null +++ b/backend/tests/ee/authentication/scim/test_filters.py @@ -0,0 +1,100 @@ +"""Unit tests for the SCIM filter parser — no database required.""" + +import pytest + +from ee.authentication.scim.filters import ( + parse_patch_path_filter, + parse_scim_filter, +) + + +class TestParseScimFilter: + """Tests for the SCIM filter expression parser.""" + + def test_simple_eq(self): + result = parse_scim_filter('userName eq "alice@example.com"') + assert result == [("username", "eq", "alice@example.com")] + + def test_eq_case_insensitive_operator(self): + result = parse_scim_filter('userName EQ "alice@example.com"') + assert result == [("username", "eq", "alice@example.com")] + + def test_and_conjunction(self): + result = parse_scim_filter( + 'userName eq "alice@example.com" and externalId eq "ext-123"' + ) + assert result == [ + ("username", "eq", "alice@example.com"), + ("externalid", "eq", "ext-123"), + ] + + def test_and_case_insensitive(self): + result = parse_scim_filter( + 'userName eq "alice@example.com" AND externalId eq "ext-123"' + ) + assert len(result) == 2 + + def test_empty_string(self): + assert parse_scim_filter("") == [] + + def test_none_value(self): + assert parse_scim_filter(None) == [] + + def test_external_id_filter(self): + result = parse_scim_filter('externalId eq "abc-123-def"') + assert result == [("externalid", "eq", "abc-123-def")] + + def test_display_name_filter(self): + result = parse_scim_filter('displayName eq "Engineering"') + assert result == [("displayname", "eq", "Engineering")] + + def test_unsupported_operator_still_parsed(self): + """Parser extracts clauses regardless of operator; queryset builder ignores non-eq.""" + result = parse_scim_filter('userName co "alice"') + assert result == [("username", "co", "alice")] + + def test_malformed_filter_no_quotes(self): + result = parse_scim_filter("userName eq alice") + assert result == [] + + def test_malformed_filter_missing_value(self): + result = parse_scim_filter('userName eq ""') + assert result == [("username", "eq", "")] + + def test_email_with_special_chars(self): + result = parse_scim_filter('userName eq "user+tag@sub.example.com"') + assert result == [("username", "eq", "user+tag@sub.example.com")] + + def test_attribute_lowercased(self): + result = parse_scim_filter('UserName eq "test"') + assert result[0][0] == "username" + + +class TestParsePatchPathFilter: + """Tests for Azure Entra ID-style PATCH member removal path parsing.""" + + def test_azure_entra_format(self): + result = parse_patch_path_filter('members[value eq "abc-123-def"]') + assert result == "abc-123-def" + + def test_uuid_value(self): + result = parse_patch_path_filter( + 'members[value eq "550e8400-e29b-41d4-a716-446655440000"]' + ) + assert result == "550e8400-e29b-41d4-a716-446655440000" + + def test_case_insensitive(self): + result = parse_patch_path_filter('members[value EQ "abc-123"]') + assert result == "abc-123" + + def test_non_member_path_returns_none(self): + assert parse_patch_path_filter("displayName") is None + + def test_plain_members_returns_none(self): + assert parse_patch_path_filter("members") is None + + def test_empty_string(self): + assert parse_patch_path_filter("") is None + + def test_malformed_bracket(self): + assert parse_patch_path_filter('members[value eq "abc"') is None diff --git a/backend/tests/ee/authentication/scim/test_groups.py b/backend/tests/ee/authentication/scim/test_groups.py new file mode 100644 index 000000000..cc65764a4 --- /dev/null +++ b/backend/tests/ee/authentication/scim/test_groups.py @@ -0,0 +1,1016 @@ +"""Tests for SCIM /Groups endpoints — fully mocked, no database. + +Covers: list, filter, create, get, replace (PUT), partial update (PATCH), delete. +""" + +import json +import uuid +from unittest.mock import MagicMock, patch, call + +import pytest +from django.db import IntegrityError + +from .conftest import ( + SCIM_CONTENT_TYPE, + make_mock_organisation, + make_mock_scim_group, + make_mock_scim_user, + make_mock_team, + make_patch_op, + make_scim_group_payload, +) + +GROUPS_URL = "/scim/v2/Groups" + +# Patch targets — where names are looked up in views/groups.py +_P = "ee.authentication.scim.views.groups" + + +def group_url(scim_group_id): + return f"{GROUPS_URL}/{scim_group_id}" + + +def _serialized_group(scim_group=None, members=None, **overrides): + """Return a fake serialized SCIM group dict.""" + sg = scim_group or make_mock_scim_group() + return { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": str(overrides.get("id", sg.id)), + "externalId": overrides.get("external_id", sg.external_id), + "displayName": overrides.get("display_name", sg.display_name), + "members": members if members is not None else [], + "meta": { + "resourceType": "Group", + "created": "2025-01-01T00:00:00+00:00", + "lastModified": "2025-01-01T00:00:00+00:00", + "location": f"https://testserver/service/scim/v2/Groups/{sg.id}", + }, + } + + +# --------------------------------------------------------------------------- +# List / Filter +# --------------------------------------------------------------------------- + + +class TestListGroups: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_list_empty(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + qs = MagicMock() + qs.count.return_value = 0 + qs.__getitem__ = MagicMock(return_value=[]) + MockSCIMGroup.objects.filter.return_value.order_by.return_value = qs + + resp = scim_client.get(GROUPS_URL) + assert resp.status_code == 200 + data = resp.json() + assert data["totalResults"] == 0 + assert data["Resources"] == [] + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_list_returns_groups(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + qs = MagicMock() + qs.count.return_value = 1 + qs.__getitem__ = MagicMock(return_value=[eng]) + MockSCIMGroup.objects.filter.return_value.order_by.return_value = qs + + mock_serialize.return_value = _serialized_group(eng) + + resp = scim_client.get(GROUPS_URL) + data = resp.json() + assert data["totalResults"] == 1 + assert data["Resources"][0]["displayName"] == "Engineering" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_list_includes_members(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + qs = MagicMock() + qs.count.return_value = 1 + qs.__getitem__ = MagicMock(return_value=[eng]) + MockSCIMGroup.objects.filter.return_value.order_by.return_value = qs + + members = [ + {"value": "user-1", "display": "Alice"}, + {"value": "user-2", "display": "Bob"}, + ] + mock_serialize.return_value = _serialized_group(eng, members=members) + + resp = scim_client.get(GROUPS_URL) + data = resp.json() + assert len(data["Resources"][0]["members"]) == 2 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.scim_filter_to_queryset") + @patch(f"{_P}.SCIMGroup") + def test_filter_by_display_name( + self, MockSCIMGroup, mock_filter_qs, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + qs = MagicMock() + MockSCIMGroup.objects.filter.return_value.order_by.return_value = qs + + filtered_qs = MagicMock() + filtered_qs.count.return_value = 1 + filtered_qs.__getitem__ = MagicMock(return_value=[eng]) + mock_filter_qs.return_value = filtered_qs + + mock_serialize.return_value = _serialized_group(eng) + + resp = scim_client.get(GROUPS_URL, {"filter": 'displayName eq "Engineering"'}) + data = resp.json() + assert data["totalResults"] == 1 + assert data["Resources"][0]["displayName"] == "Engineering" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_pagination(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + qs = MagicMock() + qs.count.return_value = 2 + qs.__getitem__ = MagicMock(return_value=[eng]) + MockSCIMGroup.objects.filter.return_value.order_by.return_value = qs + + mock_serialize.return_value = _serialized_group(eng) + + resp = scim_client.get(GROUPS_URL, {"startIndex": 1, "count": 1}) + data = resp.json() + assert data["totalResults"] == 2 + assert data["itemsPerPage"] == 1 + + +# --------------------------------------------------------------------------- +# Create (POST) +# --------------------------------------------------------------------------- + + +class TestCreateGroup: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + @patch(f"{_P}.Team") + def test_create_group( + self, MockTeam, MockSCIMGroup, MockSCIMUser, mock_add_member, + mock_serialize, mock_log, scim_client + ): + team = make_mock_team(name="Design") + MockTeam.objects.create.return_value = team + + scim_group = make_mock_scim_group(display_name="Design", external_id="design-ext-id") + MockSCIMGroup.objects.create.return_value = scim_group + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(scim_group) + + payload = make_scim_group_payload("Design", "design-ext-id") + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["displayName"] == "Design" + MockTeam.objects.create.assert_called_once() + create_kwargs = MockTeam.objects.create.call_args[1] + assert create_kwargs["is_scim_managed"] is True + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + @patch(f"{_P}.Team") + def test_create_group_with_description( + self, MockTeam, MockSCIMGroup, MockSCIMUser, mock_add_member, + mock_serialize, mock_log, scim_client + ): + team = make_mock_team(name="QA Team") + MockTeam.objects.create.return_value = team + + scim_group = make_mock_scim_group(display_name="QA Team") + MockSCIMGroup.objects.create.return_value = scim_group + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(scim_group) + + payload = make_scim_group_payload("QA Team", "qa-ext-id", description="Quality Assurance") + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + create_kwargs = MockTeam.objects.create.call_args[1] + assert create_kwargs["description"] == "Quality Assurance" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + @patch(f"{_P}.Team") + def test_create_group_with_initial_members( + self, MockTeam, MockSCIMGroup, MockSCIMUser, mock_add_member, + mock_serialize, mock_log, scim_client + ): + team = make_mock_team(name="NewTeam") + MockTeam.objects.create.return_value = team + + scim_group = make_mock_scim_group(display_name="NewTeam") + MockSCIMGroup.objects.create.return_value = scim_group + MockSCIMGroup.DoesNotExist = Exception + + alice = make_mock_scim_user(email="alice@example.com", id="alice-id") + bob = make_mock_scim_user(email="bob@example.com", id="bob-id") + MockSCIMUser.objects.filter.return_value.first.side_effect = [alice, bob] + + mock_serialize.return_value = _serialized_group(scim_group) + + payload = make_scim_group_payload( + "NewTeam", "new-ext-id", + members=[{"value": "alice-id"}, {"value": "bob-id"}], + ) + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + assert mock_add_member.call_count == 2 + + @patch(f"{_P}.log_scim_event") + def test_create_group_missing_display_name_returns_400(self, mock_log, scim_client): + payload = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "externalId": "x", + } + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 400 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMGroup") + @patch(f"{_P}.Team") + def test_create_duplicate_external_id_returns_409( + self, MockTeam, MockSCIMGroup, mock_log, scim_client + ): + team = make_mock_team() + MockTeam.objects.create.return_value = team + MockSCIMGroup.objects.create.side_effect = IntegrityError("duplicate key") + MockSCIMGroup.DoesNotExist = Exception + + payload = make_scim_group_payload("Another", "eng-ext-id") + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 409 + # Team should be cleaned up + team.delete.assert_called_once() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + @patch(f"{_P}.Team") + def test_create_group_logs_event( + self, MockTeam, MockSCIMGroup, MockSCIMUser, mock_add_member, + mock_serialize, mock_log, scim_client + ): + MockTeam.objects.create.return_value = make_mock_team() + scim_group = make_mock_scim_group(display_name="Logged Group") + MockSCIMGroup.objects.create.return_value = scim_group + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(scim_group) + + payload = make_scim_group_payload("Logged Group", "log-ext-id") + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + mock_log.assert_called() + log_call = mock_log.call_args_list[-1] + assert log_call[0][1] == "group_created" + assert log_call[1]["response_status"] == 201 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + @patch(f"{_P}.Team") + def test_create_group_truncates_long_name( + self, MockTeam, MockSCIMGroup, MockSCIMUser, mock_add_member, + mock_serialize, mock_log, scim_client + ): + team = make_mock_team() + MockTeam.objects.create.return_value = team + scim_group = make_mock_scim_group() + MockSCIMGroup.objects.create.return_value = scim_group + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(scim_group) + + long_name = "A" * 100 + payload = make_scim_group_payload(long_name, "long-ext-id") + resp = scim_client.post( + GROUPS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + create_kwargs = MockTeam.objects.create.call_args[1] + assert len(create_kwargs["name"]) == 64 + + +# --------------------------------------------------------------------------- +# Get (GET /:id) +# --------------------------------------------------------------------------- + + +class TestGetGroup: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_get_existing_group(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering", id="eng-id") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + members = [ + {"value": "u1", "display": "Alice"}, + {"value": "u2", "display": "Bob"}, + ] + mock_serialize.return_value = _serialized_group(eng, members=members) + + resp = scim_client.get(group_url("eng-id")) + assert resp.status_code == 200 + data = resp.json() + assert data["displayName"] == "Engineering" + assert len(data["members"]) == 2 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMGroup") + def test_get_nonexistent_group(self, MockSCIMGroup, mock_log, scim_client): + MockSCIMGroup.DoesNotExist = Exception + MockSCIMGroup.objects.select_related.return_value.get.side_effect = Exception("not found") + + resp = scim_client.get(group_url("nonexistent-id")) + assert resp.status_code == 404 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_get_group_members_have_correct_format( + self, MockSCIMGroup, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + members = [ + {"value": "u1", "display": "Alice"}, + {"value": "u2", "display": "Bob"}, + ] + mock_serialize.return_value = _serialized_group(eng, members=members) + + resp = scim_client.get(group_url(eng.id)) + for m in resp.json()["members"]: + assert "value" in m + assert "display" in m + + +# --------------------------------------------------------------------------- +# Replace (PUT) +# --------------------------------------------------------------------------- + + +class TestReplaceGroup: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.TeamMembership") + @patch(f"{_P}.SCIMGroup") + def test_rename_group_via_put( + self, MockSCIMGroup, MockTM, MockSCIMUser, mock_add, mock_remove, + mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering", external_id="eng-ext-id") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + # No membership changes (empty current, empty incoming) + MockTM.objects.filter.return_value.select_related.return_value = [] + mock_serialize.return_value = _serialized_group(eng, display_name="Engineering v2") + + payload = make_scim_group_payload("Engineering v2", "eng-ext-id", members=[]) + resp = scim_client.put( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert eng.display_name == "Engineering v2" + eng.team.save.assert_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.TeamMembership") + @patch(f"{_P}.SCIMGroup") + def test_put_membership_diff_adds_new_members( + self, MockSCIMGroup, MockTM, MockSCIMUser, mock_add, mock_remove, + mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + # Current: alice. Incoming: alice + carol + alice = make_mock_scim_user(email="alice@example.com", id="alice-id") + carol = make_mock_scim_user(email="carol@example.com", id="carol-id") + + # Current memberships — one membership pointing to alice + alice_tm = MagicMock() + alice_tm.org_member = alice.org_member + MockTM.objects.filter.return_value.select_related.return_value = [alice_tm] + + # When querying SCIMUser for current membership mapping, return alice + # When querying for the new member to add, return carol + def scim_user_filter_side_effect(**kwargs): + qs = MagicMock() + if kwargs.get("org_member") == alice.org_member: + qs.first.return_value = alice + elif kwargs.get("id") == "carol-id": + qs.first.return_value = carol + elif kwargs.get("id") == "alice-id": + qs.first.return_value = alice + else: + qs.first.return_value = None + return qs + + MockSCIMUser.objects.filter.side_effect = scim_user_filter_side_effect + + mock_serialize.return_value = _serialized_group(eng) + + payload = make_scim_group_payload( + "Engineering", "eng-ext-id", + members=[{"value": "alice-id"}, {"value": "carol-id"}], + ) + resp = scim_client.put( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + # carol should be added + mock_add.assert_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.TeamMembership") + @patch(f"{_P}.SCIMGroup") + def test_put_membership_diff_removes_departed_members( + self, MockSCIMGroup, MockTM, MockSCIMUser, mock_add, mock_remove, + mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + alice = make_mock_scim_user(email="alice@example.com", id="alice-id") + bob = make_mock_scim_user(email="bob@example.com", id="bob-id") + + alice_tm = MagicMock() + alice_tm.org_member = alice.org_member + bob_tm = MagicMock() + bob_tm.org_member = bob.org_member + MockTM.objects.filter.return_value.select_related.return_value = [alice_tm, bob_tm] + + def scim_user_filter_side_effect(**kwargs): + qs = MagicMock() + if kwargs.get("org_member") == alice.org_member: + qs.first.return_value = alice + elif kwargs.get("org_member") == bob.org_member: + qs.first.return_value = bob + elif kwargs.get("id") == "alice-id": + qs.first.return_value = alice + elif kwargs.get("id") == "bob-id": + qs.first.return_value = bob + else: + qs.first.return_value = None + return qs + + MockSCIMUser.objects.filter.side_effect = scim_user_filter_side_effect + mock_serialize.return_value = _serialized_group(eng) + + # Only keep alice + payload = make_scim_group_payload( + "Engineering", "eng-ext-id", + members=[{"value": "alice-id"}], + ) + resp = scim_client.put( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + # Bob should be removed + mock_remove.assert_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.TeamMembership") + @patch(f"{_P}.SCIMGroup") + def test_put_empty_members_removes_all( + self, MockSCIMGroup, MockTM, MockSCIMUser, mock_add, mock_remove, + mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + alice = make_mock_scim_user(email="alice@example.com", id="alice-id") + bob = make_mock_scim_user(email="bob@example.com", id="bob-id") + + alice_tm = MagicMock() + alice_tm.org_member = alice.org_member + bob_tm = MagicMock() + bob_tm.org_member = bob.org_member + MockTM.objects.filter.return_value.select_related.return_value = [alice_tm, bob_tm] + + def scim_user_filter_side_effect(**kwargs): + qs = MagicMock() + if kwargs.get("org_member") == alice.org_member: + qs.first.return_value = alice + elif kwargs.get("org_member") == bob.org_member: + qs.first.return_value = bob + elif kwargs.get("id") == "alice-id": + qs.first.return_value = alice + elif kwargs.get("id") == "bob-id": + qs.first.return_value = bob + else: + qs.first.return_value = None + return qs + + MockSCIMUser.objects.filter.side_effect = scim_user_filter_side_effect + mock_serialize.return_value = _serialized_group(eng, members=[]) + + payload = make_scim_group_payload("Engineering", "eng-ext-id", members=[]) + resp = scim_client.put( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + # Both alice and bob should be removed + assert mock_remove.call_count == 2 + + +# --------------------------------------------------------------------------- +# Partial update (PATCH) +# --------------------------------------------------------------------------- + + +class TestPatchGroup: + + # -- Add members -- + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_add_member_entra_style( + self, MockSCIMGroup, MockSCIMUser, mock_add_member, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + dave = make_mock_scim_user(email="dave@example.com", id="dave-id") + MockSCIMUser.objects.filter.return_value.first.return_value = dave + + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Add", "path": "members", "value": [{"value": "dave-id"}]} + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_add_member.assert_called_once() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_add_member_logs_event( + self, MockSCIMGroup, MockSCIMUser, mock_add_member, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + eve = make_mock_scim_user(email="eve@example.com", id="eve-id") + MockSCIMUser.objects.filter.return_value.first.return_value = eve + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Add", "path": "members", "value": [{"value": "eve-id"}]} + ]) + scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + # Should have at least one member_added log call + member_added_calls = [ + c for c in mock_log.call_args_list if c[0][1] == "member_added" + ] + assert len(member_added_calls) >= 1 + assert member_added_calls[0][1]["detail"]["member_email"] == "eve@example.com" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._add_member_to_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_add_member_idempotent( + self, MockSCIMGroup, MockSCIMUser, mock_add_member, mock_serialize, mock_log, scim_client + ): + """Adding a member should call _add_member_to_team (which handles dedup internally).""" + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + alice = make_mock_scim_user(email="alice@example.com", id="alice-id") + MockSCIMUser.objects.filter.return_value.first.return_value = alice + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Add", "path": "members", "value": [{"value": "alice-id"}]} + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_add_member.assert_called_once() + + # -- Remove members (Entra ID format) -- + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}.parse_patch_path_filter") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_remove_member_entra_bracket_notation( + self, MockSCIMGroup, MockSCIMUser, mock_parse_filter, mock_remove, + mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + bob = make_mock_scim_user(email="bob@example.com", id="bob-id") + MockSCIMUser.objects.filter.return_value.first.return_value = bob + mock_parse_filter.return_value = "bob-id" + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Remove", "path": f'members[value eq "bob-id"]'} + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_remove.assert_called_once() + + # -- Remove members (Okta format) -- + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_remove_member_okta_value_array( + self, MockSCIMGroup, MockSCIMUser, mock_remove, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + bob = make_mock_scim_user(email="bob@example.com", id="bob-id") + MockSCIMUser.objects.filter.return_value.first.return_value = bob + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Remove", "path": "members", "value": [{"value": "bob-id"}]} + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_remove.assert_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_remove_member_logs_event( + self, MockSCIMGroup, MockSCIMUser, mock_remove, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + bob = make_mock_scim_user(email="bob@example.com", id="bob-id") + MockSCIMUser.objects.filter.return_value.first.return_value = bob + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Remove", "path": "members", "value": [{"value": "bob-id"}]} + ]) + scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + member_removed_calls = [ + c for c in mock_log.call_args_list if c[0][1] == "member_removed" + ] + assert len(member_removed_calls) >= 1 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}._remove_member_from_team") + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.SCIMGroup") + def test_remove_nonexistent_member_is_noop( + self, MockSCIMGroup, MockSCIMUser, mock_remove, mock_serialize, mock_log, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + MockSCIMUser.objects.filter.return_value.first.return_value = None + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Remove", "path": "members", "value": [{"value": "nonexistent-id"}]} + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_remove.assert_not_called() + + # -- Rename group -- + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_rename_via_patch_replace(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(eng, display_name="Platform") + + payload = make_patch_op([ + {"op": "Replace", "path": "displayName", "value": "Platform"}, + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert eng.display_name == "Platform" + eng.team.save.assert_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_rename_via_patch_add(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(eng, display_name="Infrastructure") + + payload = make_patch_op([ + {"op": "Add", "path": "displayName", "value": "Infrastructure"}, + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert eng.display_name == "Infrastructure" + + # -- Update description -- + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_update_description_via_patch(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(eng) + + payload = make_patch_op([ + {"op": "Replace", "path": "description", "value": "The engineering team"}, + ]) + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + eng.team.save.assert_called() + + # -- No operations -- + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMGroup") + def test_patch_no_operations_returns_400(self, MockSCIMGroup, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [], + } + resp = scim_client.patch( + group_url(eng.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Delete (DELETE) +# --------------------------------------------------------------------------- + + +class TestDeleteGroup: + + @patch(f"{_P}.ServiceAccountToken") + @patch(f"{_P}.ServiceAccount") + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.SCIMGroup") + def test_delete_returns_204(self, MockSCIMGroup, mock_revoke, mock_log, MockSA, MockSAToken, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + resp = scim_client.delete(group_url(eng.id)) + assert resp.status_code == 204 + + @patch(f"{_P}.ServiceAccountToken") + @patch(f"{_P}.ServiceAccount") + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.SCIMGroup") + def test_delete_soft_deletes_team(self, MockSCIMGroup, mock_revoke, mock_log, MockSA, MockSAToken, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + scim_client.delete(group_url(eng.id)) + assert eng.team.deleted_at is not None + eng.team.save.assert_called() + + @patch(f"{_P}.ServiceAccountToken") + @patch(f"{_P}.ServiceAccount") + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.SCIMGroup") + def test_delete_removes_scim_group_record(self, MockSCIMGroup, mock_revoke, mock_log, MockSA, MockSAToken, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + scim_client.delete(group_url(eng.id)) + eng.delete.assert_called_once() + + @patch(f"{_P}.ServiceAccountToken") + @patch(f"{_P}.ServiceAccount") + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.SCIMGroup") + def test_delete_logs_event(self, MockSCIMGroup, mock_revoke, mock_log, MockSA, MockSAToken, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + scim_client.delete(group_url(eng.id)) + mock_log.assert_called() + log_call = mock_log.call_args_list[0] + assert log_call[0][1] == "group_deleted" + assert log_call[1]["response_status"] == 204 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMGroup") + def test_delete_nonexistent_returns_404(self, MockSCIMGroup, mock_log, scim_client): + MockSCIMGroup.DoesNotExist = Exception + MockSCIMGroup.objects.select_related.return_value.get.side_effect = Exception("not found") + + resp = scim_client.delete(group_url("nonexistent-id")) + assert resp.status_code == 404 + + @patch(f"{_P}.ServiceAccountToken") + @patch(f"{_P}.ServiceAccount") + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.SCIMGroup") + def test_delete_revokes_team_environment_keys( + self, MockSCIMGroup, mock_revoke, mock_log, MockSA, MockSAToken, scim_client + ): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + + scim_client.delete(group_url(eng.id)) + mock_revoke.assert_called_once_with(eng.team) + + +# --------------------------------------------------------------------------- +# Response format +# --------------------------------------------------------------------------- + + +class TestGroupResponseFormat: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_response_contains_schemas(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(eng) + + resp = scim_client.get(group_url(eng.id)) + data = resp.json() + assert "urn:ietf:params:scim:schemas:core:2.0:Group" in data["schemas"] + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_group") + @patch(f"{_P}.SCIMGroup") + def test_response_meta_location_format(self, MockSCIMGroup, mock_serialize, mock_log, scim_client): + eng = make_mock_scim_group(display_name="Engineering") + MockSCIMGroup.objects.select_related.return_value.get.return_value = eng + MockSCIMGroup.DoesNotExist = Exception + mock_serialize.return_value = _serialized_group(eng) + + resp = scim_client.get(group_url(eng.id)) + location = resp.json()["meta"]["location"] + assert "/service/scim/v2/Groups/" in location diff --git a/backend/tests/ee/authentication/scim/test_users.py b/backend/tests/ee/authentication/scim/test_users.py new file mode 100644 index 000000000..428162326 --- /dev/null +++ b/backend/tests/ee/authentication/scim/test_users.py @@ -0,0 +1,930 @@ +"""Tests for SCIM /Users endpoints — fully mocked, no database. + +Covers: list, filter, create, get, replace (PUT), partial update (PATCH), delete. +""" + +import json +import uuid +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest +from django.db import IntegrityError + +from .conftest import ( + SCIM_CONTENT_TYPE, + make_mock_organisation, + make_mock_scim_user, + make_patch_op, + make_scim_user_payload, +) + +USERS_URL = "/scim/v2/Users" + +# Patch targets — where names are looked up in views/users.py +_P = "ee.authentication.scim.views.users" + + +def user_url(scim_user_id): + return f"{USERS_URL}/{scim_user_id}" + + +def _serialized_user(scim_user=None, **overrides): + """Return a fake serialized SCIM user dict for mocking serialize_scim_user.""" + su = scim_user or make_mock_scim_user() + base = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": str(overrides.get("id", su.id)), + "externalId": overrides.get("external_id", su.external_id), + "userName": overrides.get("email", su.email), + "displayName": overrides.get("display_name", su.display_name), + "active": overrides.get("active", su.active), + "emails": [{"value": overrides.get("email", su.email), "type": "work", "primary": True}], + "name": su.scim_data.get("name", {}), + "meta": { + "resourceType": "User", + "created": "2025-01-01T00:00:00+00:00", + "lastModified": "2025-01-01T00:00:00+00:00", + "location": f"https://testserver/service/scim/v2/Users/{su.id}", + }, + } + base.update({k: v for k, v in overrides.items() if k not in ( + "id", "external_id", "email", "display_name", "active" + )}) + return base + + +# --------------------------------------------------------------------------- +# List / Filter +# --------------------------------------------------------------------------- + + +class TestListUsers: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_list_empty(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + qs = MagicMock() + qs.count.return_value = 0 + qs.__getitem__ = MagicMock(return_value=[]) + MockSCIMUser.objects.filter.return_value.order_by.return_value = qs + + resp = scim_client.get(USERS_URL) + assert resp.status_code == 200 + data = resp.json() + assert data["totalResults"] == 0 + assert data["Resources"] == [] + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_list_returns_provisioned_users(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + bob = make_mock_scim_user(email="bob@example.com") + + qs = MagicMock() + qs.count.return_value = 2 + qs.__getitem__ = MagicMock(return_value=[alice, bob]) + MockSCIMUser.objects.filter.return_value.order_by.return_value = qs + + mock_serialize.side_effect = [ + _serialized_user(alice), + _serialized_user(bob), + ] + + resp = scim_client.get(USERS_URL) + assert resp.status_code == 200 + data = resp.json() + assert data["totalResults"] == 2 + assert len(data["Resources"]) == 2 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.scim_filter_to_queryset") + @patch(f"{_P}.SCIMUser") + def test_filter_by_username(self, MockSCIMUser, mock_filter_qs, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + + qs = MagicMock() + MockSCIMUser.objects.filter.return_value.order_by.return_value = qs + + filtered_qs = MagicMock() + filtered_qs.count.return_value = 1 + filtered_qs.__getitem__ = MagicMock(return_value=[alice]) + mock_filter_qs.return_value = filtered_qs + + mock_serialize.return_value = _serialized_user(alice) + + resp = scim_client.get(USERS_URL, {"filter": 'userName eq "alice@example.com"'}) + assert resp.status_code == 200 + data = resp.json() + assert data["totalResults"] == 1 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_list_pagination_count(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + + qs = MagicMock() + qs.count.return_value = 2 + qs.__getitem__ = MagicMock(return_value=[alice]) + MockSCIMUser.objects.filter.return_value.order_by.return_value = qs + + mock_serialize.return_value = _serialized_user(alice) + + resp = scim_client.get(USERS_URL, {"startIndex": 1, "count": 1}) + data = resp.json() + assert data["totalResults"] == 2 + assert data["itemsPerPage"] == 1 + + +# --------------------------------------------------------------------------- +# Create (POST) +# --------------------------------------------------------------------------- + + +class TestCreateUser: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_new_user(self, mock_quota, mock_provision, mock_serialize, mock_log, scim_client): + scim_user = make_mock_scim_user(email="carol@example.com", external_id="carol-ext-id") + mock_provision.return_value = scim_user + mock_serialize.return_value = _serialized_user(scim_user) + + payload = make_scim_user_payload("carol@example.com", "carol-ext-id") + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["userName"] == "carol@example.com" + mock_provision.assert_called_once() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_user_calls_provision_with_correct_args( + self, mock_quota, mock_provision, mock_serialize, mock_log, scim_client, mock_organisation + ): + scim_user = make_mock_scim_user(email="test@example.com") + mock_provision.return_value = scim_user + mock_serialize.return_value = _serialized_user(scim_user) + + payload = make_scim_user_payload("test@example.com", "test-ext-id", display_name="Test User") + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + call_kwargs = mock_provision.call_args + assert call_kwargs[1]["email"] == "test@example.com" + assert call_kwargs[1]["external_id"] == "test-ext-id" + assert call_kwargs[1]["display_name"] == "Test User" + assert call_kwargs[1]["scim_data"] == payload + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_duplicate_returns_409(self, mock_quota, mock_provision, mock_log, scim_client): + mock_provision.side_effect = IntegrityError("duplicate key") + + payload = make_scim_user_payload("dup@example.com", "dup-ext-id") + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 409 + assert "uniqueness" in resp.json().get("scimType", "") + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_missing_username_returns_400(self, mock_quota, mock_log, scim_client): + payload = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId": "x", + } + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 400 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_missing_external_id_returns_400(self, mock_quota, mock_log, scim_client): + payload = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "test@example.com", + } + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 400 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_user_with_active_false( + self, mock_quota, mock_provision, mock_deactivate, mock_serialize, mock_log, scim_client + ): + scim_user = make_mock_scim_user(email="inactive@example.com", active=False) + mock_provision.return_value = scim_user + mock_serialize.return_value = _serialized_user(scim_user, active=False) + + payload = make_scim_user_payload("inactive@example.com", "inactive-ext", active=False) + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + mock_deactivate.assert_called_once_with(scim_user) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_user_logs_event( + self, mock_quota, mock_provision, mock_serialize, mock_log, scim_client + ): + scim_user = make_mock_scim_user(email="logged@example.com") + mock_provision.return_value = scim_user + mock_serialize.return_value = _serialized_user(scim_user) + + payload = make_scim_user_payload("logged@example.com", "logged-ext") + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + mock_log.assert_called() + # The final log call should have event_type="user_created" and response_status=201 + final_call = mock_log.call_args_list[-1] + assert final_call[0][1] == "user_created" + assert final_call[1]["response_status"] == 201 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_user_display_name_from_name_parts( + self, mock_quota, mock_provision, mock_serialize, mock_log, scim_client + ): + """If displayName is missing, _extract_user_fields builds it from name parts.""" + scim_user = make_mock_scim_user(email="nodisp@example.com", display_name="Jane Doe") + mock_provision.return_value = scim_user + mock_serialize.return_value = _serialized_user(scim_user, display_name="Jane Doe") + + payload = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId": "nodisp-ext", + "userName": "nodisp@example.com", + "name": {"givenName": "Jane", "familyName": "Doe"}, + "active": True, + } + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + call_kwargs = mock_provision.call_args[1] + assert call_kwargs["display_name"] == "Jane Doe" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.provision_scim_user") + @patch(f"{_P}.can_add_account", return_value=True) + def test_create_user_email_from_emails_array( + self, mock_quota, mock_provision, mock_serialize, mock_log, scim_client + ): + """If userName is empty, falls back to emails array.""" + scim_user = make_mock_scim_user(email="fallback@example.com") + mock_provision.return_value = scim_user + mock_serialize.return_value = _serialized_user(scim_user, email="fallback@example.com") + + payload = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId": "emails-ext", + "userName": "", + "emails": [{"value": "fallback@example.com", "type": "work", "primary": True}], + "active": True, + } + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 201 + call_kwargs = mock_provision.call_args[1] + assert call_kwargs["email"] == "fallback@example.com" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.can_add_account", return_value=False) + def test_create_user_seat_limit_returns_403(self, mock_quota, mock_log, scim_client): + payload = make_scim_user_payload("over@example.com", "over-ext") + resp = scim_client.post( + USERS_URL, + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Get (GET /:id) +# --------------------------------------------------------------------------- + + +class TestGetUser: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_get_existing_user(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", id="alice-id") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice) + + resp = scim_client.get(user_url("alice-id")) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == str(alice.id) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMUser") + def test_get_nonexistent_user(self, MockSCIMUser, mock_log, scim_client): + MockSCIMUser.DoesNotExist = Exception + MockSCIMUser.objects.select_related.return_value.get.side_effect = Exception("not found") + + resp = scim_client.get(user_url("nonexistent-id")) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Replace (PUT) +# --------------------------------------------------------------------------- + + +class TestReplaceUser: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_update_display_name_via_put(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", display_name="Alice Test") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, display_name="Alice Updated") + + payload = make_scim_user_payload( + "alice@example.com", "alice-ext-id", + display_name="Alice Updated", + ) + resp = scim_client.put( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert resp.json()["displayName"] == "Alice Updated" + assert alice.display_name == "Alice Updated" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_deactivate_via_put(self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_scim_user_payload("alice@example.com", "alice-ext-id", active=False) + resp = scim_client.put( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is False + mock_deactivate.assert_called_once_with(alice) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_deactivate_via_put_logs_correct_event_type( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_scim_user_payload("alice@example.com", "alice-ext-id", active=False) + scim_client.put( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + log_call = mock_log.call_args_list[-1] + assert log_call[0][1] == "user_deactivated" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.reactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_reactivate_via_put(self, MockSCIMUser, mock_reactivate, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", active=False) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=True) + + payload = make_scim_user_payload("alice@example.com", "alice-ext-id", active=True) + resp = scim_client.put( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is True + mock_reactivate.assert_called_once_with(alice) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.reactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_reactivate_via_put_logs_correct_event_type( + self, MockSCIMUser, mock_reactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=False) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=True) + + payload = make_scim_user_payload("alice@example.com", "alice-ext-id", active=True) + scim_client.put( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + log_call = mock_log.call_args_list[-1] + assert log_call[0][1] == "user_reactivated" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_update_email_via_put(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, email="alice-new@example.com") + + payload = make_scim_user_payload("alice-new@example.com", "alice-ext-id") + resp = scim_client.put( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert alice.email == "alice-new@example.com" + alice.user.save.assert_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMUser") + def test_put_nonexistent_user_returns_404(self, MockSCIMUser, mock_log, scim_client): + MockSCIMUser.DoesNotExist = Exception + MockSCIMUser.objects.select_related.return_value.get.side_effect = Exception("not found") + + payload = make_scim_user_payload("nobody@example.com", "nobody-ext") + resp = scim_client.put( + user_url("nonexistent"), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Partial update (PATCH) +# --------------------------------------------------------------------------- + + +class TestPatchUser: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_deactivate_via_patch_entra_style( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_patch_op([ + {"op": "Replace", "path": "active", "value": "False"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is False + mock_deactivate.assert_called_once_with(alice) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_deactivate_via_patch_bool_value( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_patch_op([ + {"op": "Replace", "path": "active", "value": False}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_deactivate.assert_called_once() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_deactivate_via_patch_valueless_replace( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + """Valueless replace with value as a dict.""" + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_patch_op([ + {"op": "Replace", "value": {"active": False}}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_deactivate.assert_called_once() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_deactivate_via_patch_valueless_string_active( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_patch_op([ + {"op": "Replace", "value": {"active": "false"}}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_deactivate.assert_called_once() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.reactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_reactivate_via_patch( + self, MockSCIMUser, mock_reactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=False) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=True) + + payload = make_patch_op([ + {"op": "Replace", "path": "active", "value": "True"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert resp.json()["active"] is True + mock_reactivate.assert_called_once_with(alice) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_patch_username(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, email="alice-renamed@example.com") + + payload = make_patch_op([ + {"op": "Replace", "path": "userName", "value": "alice-renamed@example.com"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert alice.email == "alice-renamed@example.com" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_patch_display_name(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", display_name="Alice Test") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, display_name="Alice Wonderland") + + payload = make_patch_op([ + {"op": "Replace", "path": "displayName", "value": "Alice Wonderland"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert alice.display_name == "Alice Wonderland" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_patch_name_given_name(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user( + email="alice@example.com", + scim_data={"name": {"givenName": "Alice", "familyName": "Test"}}, + ) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice) + + payload = make_patch_op([ + {"op": "Replace", "path": "name.givenName", "value": "Alicia"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert "Alicia" in alice.display_name + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_patch_name_family_name(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user( + email="alice@example.com", + scim_data={"name": {"givenName": "Alice", "familyName": "Test"}}, + ) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice) + + payload = make_patch_op([ + {"op": "Replace", "path": "name.familyName", "value": "Wonderland"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + assert "Wonderland" in alice.display_name + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_patch_deactivate_logs_correct_event_type( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_patch_op([ + {"op": "Replace", "path": "active", "value": "False"}, + ]) + scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + log_call = mock_log.call_args_list[-1] + assert log_call[0][1] == "user_deactivated" + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMUser") + def test_patch_no_operations_returns_400(self, MockSCIMUser, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [], + } + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 400 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_patch_entra_multi_op( + self, MockSCIMUser, mock_deactivate, mock_serialize, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice, active=False) + + payload = make_patch_op([ + {"op": "Replace", "path": "active", "value": "False"}, + {"op": "Add", "path": "title", "value": "Engineer"}, + ]) + resp = scim_client.patch( + user_url(alice.id), + data=json.dumps(payload), + content_type=SCIM_CONTENT_TYPE, + ) + assert resp.status_code == 200 + mock_deactivate.assert_called_once() + + +# --------------------------------------------------------------------------- +# Delete (DELETE) +# --------------------------------------------------------------------------- + + +class TestDeleteUser: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_delete_returns_204(self, MockSCIMUser, mock_deactivate, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + + resp = scim_client.delete(user_url(alice.id)) + assert resp.status_code == 204 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_delete_calls_deactivate(self, MockSCIMUser, mock_deactivate, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + + scim_client.delete(user_url(alice.id)) + mock_deactivate.assert_called_once_with(alice) + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_delete_already_inactive_skips_deactivate( + self, MockSCIMUser, mock_deactivate, mock_log, scim_client + ): + alice = make_mock_scim_user(email="alice@example.com", active=False) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + + resp = scim_client.delete(user_url(alice.id)) + assert resp.status_code == 204 + mock_deactivate.assert_not_called() + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.deactivate_scim_user") + @patch(f"{_P}.SCIMUser") + def test_delete_logs_event(self, MockSCIMUser, mock_deactivate, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com", active=True) + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + + scim_client.delete(user_url(alice.id)) + mock_log.assert_called() + log_call = mock_log.call_args_list[-1] + assert log_call[0][1] == "user_deactivated" + assert log_call[1]["response_status"] == 204 + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMUser") + def test_delete_nonexistent_returns_404(self, MockSCIMUser, mock_log, scim_client): + MockSCIMUser.DoesNotExist = Exception + MockSCIMUser.objects.select_related.return_value.get.side_effect = Exception("not found") + + resp = scim_client.delete(user_url("nonexistent-id")) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Response format validation +# --------------------------------------------------------------------------- + + +class TestUserResponseFormat: + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_response_contains_schemas(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice) + + resp = scim_client.get(user_url(alice.id)) + data = resp.json() + assert "schemas" in data + assert "urn:ietf:params:scim:schemas:core:2.0:User" in data["schemas"] + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_response_meta_location_format(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + alice = make_mock_scim_user(email="alice@example.com") + MockSCIMUser.objects.select_related.return_value.get.return_value = alice + MockSCIMUser.DoesNotExist = Exception + mock_serialize.return_value = _serialized_user(alice) + + resp = scim_client.get(user_url(alice.id)) + location = resp.json()["meta"]["location"] + assert "/service/scim/v2/Users/" in location + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.serialize_scim_user") + @patch(f"{_P}.SCIMUser") + def test_list_response_format(self, MockSCIMUser, mock_serialize, mock_log, scim_client): + qs = MagicMock() + qs.count.return_value = 0 + qs.__getitem__ = MagicMock(return_value=[]) + MockSCIMUser.objects.filter.return_value.order_by.return_value = qs + + resp = scim_client.get(USERS_URL) + data = resp.json() + assert "urn:ietf:params:scim:api:messages:2.0:ListResponse" in data["schemas"] + assert "totalResults" in data + assert "startIndex" in data + assert "itemsPerPage" in data + assert "Resources" in data + + @patch(f"{_P}.log_scim_event") + @patch(f"{_P}.SCIMUser") + def test_error_response_format(self, MockSCIMUser, mock_log, scim_client): + MockSCIMUser.DoesNotExist = Exception + MockSCIMUser.objects.select_related.return_value.get.side_effect = Exception("not found") + + resp = scim_client.get(user_url("nonexistent")) + data = resp.json() + assert "urn:ietf:params:scim:api:messages:2.0:Error" in data["schemas"] + assert "status" in data + assert "detail" in data diff --git a/backend/tests/ee/authentication/scim/test_utils.py b/backend/tests/ee/authentication/scim/test_utils.py new file mode 100644 index 000000000..c0313135d --- /dev/null +++ b/backend/tests/ee/authentication/scim/test_utils.py @@ -0,0 +1,367 @@ +"""Tests for SCIM provisioning utility functions — fully mocked, no database. + +Covers: provision_scim_user, deactivate_scim_user, reactivate_scim_user. +""" + +from unittest.mock import MagicMock, call, patch + +import pytest +from django.utils import timezone + +from .conftest import make_mock_org_member, make_mock_organisation, make_mock_scim_user, make_mock_user + +# Patch targets — where names are looked up in utils.py +_P = "ee.authentication.scim.utils" + + +# --------------------------------------------------------------------------- +# provision_scim_user +# --------------------------------------------------------------------------- + + +class TestProvisionScimUser: + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_creates_new_user_and_org_member( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + role = MagicMock(name="Developer") + MockRole.objects.get.return_value = role + MockCustomUser.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.filter.return_value.first.return_value = None + + new_user = make_mock_user(email="new@example.com") + MockCustomUser.objects.create.return_value = new_user + + new_member = make_mock_org_member(user=new_user, organisation=org) + MockOrgMember.objects.create.return_value = new_member + + scim_user = MagicMock() + MockSCIMUser.objects.create.return_value = scim_user + + result = provision_scim_user(org, "ext-1", "new@example.com", "New User") + + assert result is scim_user + MockCustomUser.objects.create.assert_called_once_with( + username="new@example.com", email="new@example.com" + ) + MockOrgMember.objects.create.assert_called_once() + MockSCIMUser.objects.create.assert_called_once() + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_new_user_has_unusable_password( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + MockRole.objects.get.return_value = MagicMock() + MockCustomUser.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.filter.return_value.first.return_value = None + + new_user = make_mock_user() + MockCustomUser.objects.create.return_value = new_user + MockOrgMember.objects.create.return_value = make_mock_org_member() + MockSCIMUser.objects.create.return_value = MagicMock() + + provision_scim_user(org, "ext", "test@example.com", "Test") + + new_user.set_unusable_password.assert_called_once() + new_user.save.assert_called_once() + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_new_user_has_empty_crypto_material( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + MockRole.objects.get.return_value = MagicMock() + MockCustomUser.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.filter.return_value.first.return_value = None + + MockCustomUser.objects.create.return_value = make_mock_user() + MockOrgMember.objects.create.return_value = make_mock_org_member() + MockSCIMUser.objects.create.return_value = MagicMock() + + provision_scim_user(org, "ext", "test@example.com", "Test") + + create_kwargs = MockOrgMember.objects.create.call_args + assert create_kwargs[1]["identity_key"] == "" + assert create_kwargs[1]["wrapped_keyring"] == "" + assert create_kwargs[1]["wrapped_recovery"] == "" + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_email_normalized_to_lowercase( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + MockRole.objects.get.return_value = MagicMock() + MockCustomUser.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.filter.return_value.first.return_value = None + MockCustomUser.objects.create.return_value = make_mock_user() + MockOrgMember.objects.create.return_value = make_mock_org_member() + MockSCIMUser.objects.create.return_value = MagicMock() + + provision_scim_user(org, "ext", "UPPER@EXAMPLE.COM", "Test") + + create_kwargs = MockSCIMUser.objects.create.call_args + assert create_kwargs[1]["email"] == "upper@example.com" + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_links_existing_custom_user( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + MockRole.objects.get.return_value = MagicMock() + + existing_user = make_mock_user(email="existing@example.com") + MockCustomUser.objects.filter.return_value.first.return_value = existing_user + MockOrgMember.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.create.return_value = make_mock_org_member(user=existing_user) + MockSCIMUser.objects.create.return_value = MagicMock() + + provision_scim_user(org, "ext", "existing@example.com", "Existing") + + MockCustomUser.objects.create.assert_not_called() + scim_create_kwargs = MockSCIMUser.objects.create.call_args[1] + assert scim_create_kwargs["user"] is existing_user + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_reactivates_soft_deleted_org_member( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + MockRole.objects.get.return_value = MagicMock() + + existing_user = make_mock_user(email="softdel@example.com") + MockCustomUser.objects.filter.return_value.first.return_value = existing_user + + soft_deleted_member = make_mock_org_member( + user=existing_user, organisation=org, deleted_at=timezone.now() + ) + MockOrgMember.objects.filter.return_value.first.return_value = soft_deleted_member + MockSCIMUser.objects.create.return_value = MagicMock() + + provision_scim_user(org, "ext", "softdel@example.com", "Soft Del") + + assert soft_deleted_member.deleted_at is None + soft_deleted_member.save.assert_called_once_with(update_fields=["deleted_at"]) + MockOrgMember.objects.create.assert_not_called() + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_stores_scim_data(self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + MockRole.objects.get.return_value = MagicMock() + MockCustomUser.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.filter.return_value.first.return_value = None + MockCustomUser.objects.create.return_value = make_mock_user() + MockOrgMember.objects.create.return_value = make_mock_org_member() + MockSCIMUser.objects.create.return_value = MagicMock() + + scim_data = {"userName": "data@example.com", "custom": "field"} + provision_scim_user(org, "ext", "data@example.com", "Data", scim_data=scim_data) + + create_kwargs = MockSCIMUser.objects.create.call_args[1] + assert create_kwargs["scim_data"] is scim_data + + @patch(f"{_P}.SCIMUser") + @patch(f"{_P}.OrganisationMember") + @patch(f"{_P}.CustomUser") + @patch(f"{_P}.Role") + def test_default_role_is_developer( + self, MockRole, MockCustomUser, MockOrgMember, MockSCIMUser + ): + from ee.authentication.scim.utils import provision_scim_user + + org = make_mock_organisation() + dev_role = MagicMock(name="Developer") + MockRole.objects.get.return_value = dev_role + MockCustomUser.objects.filter.return_value.first.return_value = None + MockOrgMember.objects.filter.return_value.first.return_value = None + MockCustomUser.objects.create.return_value = make_mock_user() + MockOrgMember.objects.create.return_value = make_mock_org_member() + MockSCIMUser.objects.create.return_value = MagicMock() + + provision_scim_user(org, "ext", "test@example.com", "Test") + + MockRole.objects.get.assert_called_once_with(organisation=org, name__iexact="developer") + create_kwargs = MockOrgMember.objects.create.call_args[1] + assert create_kwargs["role"] is dev_role + + +# --------------------------------------------------------------------------- +# deactivate_scim_user +# --------------------------------------------------------------------------- + + +class TestDeactivateScimUser: + + def _empty_qs(self): + """Return a MagicMock that behaves like an empty queryset (iterable + .delete()).""" + qs = MagicMock() + qs.__iter__ = MagicMock(return_value=iter([])) + return qs + + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.TeamMembership") + def test_sets_active_false(self, MockTM, mock_revoke): + from ee.authentication.scim.utils import deactivate_scim_user + + scim_user = make_mock_scim_user(active=True) + MockTM.objects.filter.return_value.select_related.return_value = self._empty_qs() + + deactivate_scim_user(scim_user) + + assert scim_user.active is False + scim_user.save.assert_called_with(update_fields=["active"]) + + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.TeamMembership") + def test_soft_deletes_org_member(self, MockTM, mock_revoke): + from ee.authentication.scim.utils import deactivate_scim_user + + scim_user = make_mock_scim_user() + MockTM.objects.filter.return_value.select_related.return_value = self._empty_qs() + + deactivate_scim_user(scim_user) + + assert scim_user.org_member.deleted_at is not None + scim_user.org_member.save.assert_called() + + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.TeamMembership") + def test_wipes_crypto_material(self, MockTM, mock_revoke): + from ee.authentication.scim.utils import deactivate_scim_user + + scim_user = make_mock_scim_user() + scim_user.org_member.identity_key = "some-key" + scim_user.org_member.wrapped_keyring = "some-keyring" + scim_user.org_member.wrapped_recovery = "some-recovery" + MockTM.objects.filter.return_value.select_related.return_value = self._empty_qs() + + deactivate_scim_user(scim_user) + + assert scim_user.org_member.identity_key == "" + assert scim_user.org_member.wrapped_keyring == "" + assert scim_user.org_member.wrapped_recovery == "" + + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.TeamMembership") + def test_revokes_keys_and_deletes_team_memberships(self, MockTM, mock_revoke): + from ee.authentication.scim.utils import deactivate_scim_user + + scim_user = make_mock_scim_user() + team1 = MagicMock(name="Team1") + tm1 = MagicMock(team=team1) + team2 = MagicMock(name="Team2") + tm2 = MagicMock(team=team2) + + qs = MagicMock() + qs.__iter__ = MagicMock(return_value=iter([tm1, tm2])) + MockTM.objects.filter.return_value.select_related.return_value = qs + + deactivate_scim_user(scim_user) + + mock_revoke.assert_any_call(team1, member=scim_user.org_member) + mock_revoke.assert_any_call(team2, member=scim_user.org_member) + assert mock_revoke.call_count == 2 + qs.delete.assert_called_once() + + @patch(f"{_P}.revoke_team_environment_keys") + @patch(f"{_P}.TeamMembership") + def test_no_org_member_is_safe(self, MockTM, mock_revoke): + """If org_member is None, deactivation should still set active=False.""" + from ee.authentication.scim.utils import deactivate_scim_user + + scim_user = make_mock_scim_user() + scim_user.org_member = None + + deactivate_scim_user(scim_user) + + assert scim_user.active is False + MockTM.objects.filter.assert_not_called() + + +# --------------------------------------------------------------------------- +# reactivate_scim_user +# --------------------------------------------------------------------------- + + +class TestReactivateScimUser: + + def test_sets_active_true(self): + from ee.authentication.scim.utils import reactivate_scim_user + + scim_user = make_mock_scim_user(active=False) + scim_user.org_member.deleted_at = "2025-01-01" + + reactivate_scim_user(scim_user) + + assert scim_user.active is True + scim_user.save.assert_called_with(update_fields=["active"]) + + def test_clears_deleted_at(self): + from ee.authentication.scim.utils import reactivate_scim_user + + scim_user = make_mock_scim_user(active=False) + scim_user.org_member.deleted_at = "2025-01-01" + + reactivate_scim_user(scim_user) + + assert scim_user.org_member.deleted_at is None + scim_user.org_member.save.assert_called_with(update_fields=["deleted_at"]) + + def test_no_op_if_org_member_not_deleted(self): + from ee.authentication.scim.utils import reactivate_scim_user + + scim_user = make_mock_scim_user(active=False) + scim_user.org_member.deleted_at = None + + reactivate_scim_user(scim_user) + + assert scim_user.active is True + # org_member.save should NOT be called because deleted_at was already None + scim_user.org_member.save.assert_not_called() + + def test_no_org_member_is_safe(self): + from ee.authentication.scim.utils import reactivate_scim_user + + scim_user = make_mock_scim_user(active=False) + scim_user.org_member = None + + reactivate_scim_user(scim_user) + + assert scim_user.active is True diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py index 5e69033b7..43ab5b68c 100644 --- a/backend/tests/test_auth_password.py +++ b/backend/tests/test_auth_password.py @@ -1230,10 +1230,10 @@ def test_sso_user_recovery_rejected(self): @patch("backend.graphene.mutations.organisation.OrganisationMember") @patch("backend.graphene.mutations.organisation.Organisation") def test_sso_recovery_rewrap_requires_identity_proof(self, mock_org, mock_om): - """SSO recovery via UpdateUserWrappedSecretsMutation must reject - when supplied identity_key doesn't match — without this proof an - authenticated user (or session-cookie holder) could overwrite - their wrapped_keyring with arbitrary garbage.""" + """Re-wrap of an existing keyring must reject when supplied + identity_key doesn't match the member's stored one — without + this proof an authenticated user (or session-cookie holder) + could overwrite their wrapped_keyring with a foreign identity.""" from graphql import GraphQLError from backend.graphene.mutations.organisation import ( UpdateUserWrappedSecretsMutation, @@ -1290,6 +1290,45 @@ def test_sso_recovery_rewrap_succeeds_with_valid_identity( org_member.save.assert_called_once() self.assertIs(result.org_member, org_member) + @patch("backend.graphene.mutations.organisation.provision_pending_team_keys") + @patch("backend.graphene.mutations.organisation.OrganisationMember") + @patch("backend.graphene.mutations.organisation.Organisation") + def test_first_key_ceremony_skips_identity_proof( + self, mock_org, mock_om, mock_provision + ): + """SCIM-provisioned members have no prior identity_key — they + are establishing one for the first time, so the proof check + must be skipped. A blank stored identity_key would otherwise + reject every legitimate first-ceremony call.""" + from backend.graphene.mutations.organisation import ( + UpdateUserWrappedSecretsMutation, + ) + user = MagicMock() + + org = MagicMock() + mock_org.objects.get.return_value = org + + org_member = MagicMock() + org_member.identity_key = "" # SCIM-preprovisioned, never set + mock_om.objects.get.return_value = org_member + + result = UpdateUserWrappedSecretsMutation.mutate( + None, + self._info(user), + org_id="org-1", + identity_key="newly-derived-key", + wrapped_keyring="new_wk", + wrapped_recovery="new_wr", + ) + + self.assertEqual(org_member.identity_key, "newly-derived-key") + self.assertEqual(org_member.wrapped_keyring, "new_wk") + self.assertEqual(org_member.wrapped_recovery, "new_wr") + org_member.save.assert_called_once() + # Team env keys are provisioned for SCIM members on first ceremony. + mock_provision.assert_called_once_with(org_member) + self.assertIs(result.org_member, org_member) + class CrossAuthMethodTest(_ThrottleClearMixin, unittest.TestCase): """Tests for cross-auth-method edge cases.""" diff --git a/backend/tests/test_org_resolution.py b/backend/tests/test_org_resolution.py index 5917f22eb..c88783844 100644 --- a/backend/tests/test_org_resolution.py +++ b/backend/tests/test_org_resolution.py @@ -261,9 +261,10 @@ def test_sso_session_bound_to_org_skips_db(self, mock_org_cls): self.assertEqual(result, "ran") mock_org_cls.objects.only.return_value.get.assert_not_called() + @patch("backend.graphene.middleware.OrganisationMember") @patch("backend.graphene.middleware.Organisation") def test_sso_session_bound_to_different_org_does_not_skip( - self, mock_org_cls + self, mock_org_cls, mock_om_cls ): """Session bound to org A must NOT short-circuit for requests targeting org B — the DB check must run to enforce B's require_sso.""" @@ -273,6 +274,7 @@ def test_sso_session_bound_to_different_org_does_not_skip( org = MagicMock(require_sso=False) org.name = "acme" mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False mw = OrgSSOEnforcementMiddleware() mw.resolve( @@ -285,5 +287,174 @@ def test_sso_session_bound_to_different_org_does_not_skip( mock_org_cls.objects.only.return_value.get.assert_called_once() +class MiddlewareSCIMEnforcementTest(unittest.TestCase): + """SCIM-managed members must access via SSO regardless of the org's + require_sso flag — the IdP is the source of truth for that + membership. Per-org check, so multi-org users with non-SCIM + memberships elsewhere are unaffected.""" + + class _StubUser: + is_authenticated = True + + def __init__(self, user_id="user-1"): + self.userId = user_id + + class _StubRequest: + def __init__(self, session, user=None): + self.user = user or MiddlewareSCIMEnforcementTest._StubUser() + self.session = session + + def _info(self, session, user=None): + info = MagicMock() + info.context = self._StubRequest(session, user) + info.return_type = MagicMock() + return info + + def _next(self, root, info, **kwargs): + return "ran" + + def setUp(self): + cache.clear() + + @patch("backend.graphene.middleware.OrganisationMember") + @patch("backend.graphene.middleware.Organisation") + def test_password_session_scim_managed_blocks(self, mock_org_cls, mock_om_cls): + """Password-auth user accessing an org where they're SCIM-managed + must be rejected even when require_sso is False.""" + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + org = MagicMock(require_sso=False) + org.name = "acme" + mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = True + + mw = OrgSSOEnforcementMiddleware() + with self.assertRaises(SSORequiredError): + mw.resolve( + self._next, + None, + self._info({"auth_method": "password"}), + organisation_id="org-1", + ) + + @patch("backend.graphene.middleware.OrganisationMember") + @patch("backend.graphene.middleware.Organisation") + def test_password_session_non_scim_member_allowed( + self, mock_org_cls, mock_om_cls + ): + """Regression: password-auth user in an org with require_sso=False + and no SCIM membership passes through — no false positives from + the new branch.""" + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + org = MagicMock(require_sso=False) + org.name = "acme" + mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False + + mw = OrgSSOEnforcementMiddleware() + result = mw.resolve( + self._next, + None, + self._info({"auth_method": "password"}), + organisation_id="org-1", + ) + self.assertEqual(result, "ran") + + @patch("backend.graphene.middleware.OrganisationMember") + @patch("backend.graphene.middleware.Organisation") + def test_scim_member_with_org_bound_sso_session_allowed( + self, mock_org_cls, mock_om_cls + ): + """SCIM-managed member with a session SSO-bound to that org skips + all checks via the existing fast path — SCIM lookup never even + runs.""" + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + mw = OrgSSOEnforcementMiddleware() + result = mw.resolve( + self._next, + None, + self._info({"auth_method": "sso", "auth_sso_org_id": "org-1"}), + organisation_id="org-1", + ) + self.assertEqual(result, "ran") + mock_org_cls.objects.only.return_value.get.assert_not_called() + mock_om_cls.objects.filter.assert_not_called() + + @patch("backend.graphene.middleware.OrganisationMember") + @patch("backend.graphene.middleware.Organisation") + def test_password_session_scim_in_other_org_allowed_for_password_org( + self, mock_org_cls, mock_om_cls + ): + """Multi-org isolation: a user who is SCIM-managed in org A can + still password-auth into org C where they're a regular member — + SCIM enforcement is per-(user, org), not global.""" + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + org_c = MagicMock(require_sso=False) + org_c.name = "personal" + mock_org_cls.objects.only.return_value.get.return_value = org_c + # No SCIM membership in org C even though the user is SCIM-managed elsewhere. + mock_om_cls.objects.filter.return_value.exists.return_value = False + + mw = OrgSSOEnforcementMiddleware() + result = mw.resolve( + self._next, + None, + self._info({"auth_method": "password"}), + organisation_id="org-C", + ) + self.assertEqual(result, "ran") + + @patch("backend.graphene.middleware._bypasses_sso_enforcement", return_value=True) + @patch("backend.graphene.middleware.OrganisationMember") + @patch("backend.graphene.middleware.Organisation") + def test_bypass_flag_overrides_scim_enforcement( + self, mock_org_cls, mock_om_cls, _mock_bypass + ): + """SSO admin recovery mutations carry bypass_sso_enforcement=True; + SCIM enforcement must not block them, otherwise an admin who + accidentally SCIM-locked themselves can't recover.""" + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + mw = OrgSSOEnforcementMiddleware() + result = mw.resolve( + self._next, + None, + self._info({"auth_method": "password"}), + organisation_id="org-1", + ) + self.assertEqual(result, "ran") + # Bypass short-circuits before any DB query. + mock_org_cls.objects.only.return_value.get.assert_not_called() + mock_om_cls.objects.filter.assert_not_called() + + @patch("backend.graphene.middleware.OrganisationMember") + @patch("backend.graphene.middleware.Organisation") + def test_scim_lookup_cached_per_request(self, mock_org_cls, mock_om_cls): + """Within a single request, the SCIM membership query runs once + per (user, org). A second resolver call with the same kwargs hits + the request-scoped cache instead of the DB.""" + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + org = MagicMock(require_sso=False) + org.name = "acme" + mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False + + mw = OrgSSOEnforcementMiddleware() + info = self._info({"auth_method": "password"}) + + mw.resolve(self._next, None, info, organisation_id="org-1") + mw.resolve(self._next, None, info, organisation_id="org-1") + + # filter() called once across both resolves. + self.assertEqual(mock_om_cls.objects.filter.call_count, 1) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_org_sso.py b/backend/tests/test_org_sso.py index 24aac455a..a5abeeb97 100644 --- a/backend/tests/test_org_sso.py +++ b/backend/tests/test_org_sso.py @@ -1134,12 +1134,14 @@ def _make_info(self, user_authenticated=True, session_auth_method=None, session_ def _next(self, root, info, **kwargs): return "resolver_ran" + @patch("backend.graphene.middleware.OrganisationMember") @patch("backend.graphene.middleware.Organisation") - def test_passes_when_org_does_not_require_sso(self, mock_org_cls): + def test_passes_when_org_does_not_require_sso(self, mock_org_cls, mock_om_cls): from backend.graphene.middleware import OrgSSOEnforcementMiddleware org = MagicMock(require_sso=False, name="acme", id="org-1") mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False mw = OrgSSOEnforcementMiddleware() info = self._make_info(session_auth_method="password") @@ -1658,8 +1660,9 @@ def setUp(self): # caching behaviour is independent of the enforcement decision, and # these tests aren't exercising the enforcement branch. + @patch("backend.graphene.middleware.OrganisationMember") @patch("backend.graphene.middleware.Organisation") - def test_org_lookup_cached_across_calls(self, mock_org_cls): + def test_org_lookup_cached_across_calls(self, mock_org_cls, mock_om_cls): """A single GraphQL document often pulls many org-scoped fields — they must all share one Organisation lookup, not re-query each time.""" from backend.graphene.middleware import OrgSSOEnforcementMiddleware @@ -1667,6 +1670,7 @@ def test_org_lookup_cached_across_calls(self, mock_org_cls): org = MagicMock(require_sso=False) org.name = "acme" mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False mw = OrgSSOEnforcementMiddleware() info = self._make_info_with_real_request() @@ -1679,8 +1683,11 @@ def test_org_lookup_cached_across_calls(self, mock_org_cls): mock_org_cls.objects.only.return_value.get.call_count, 1 ) + @patch("backend.graphene.middleware.OrganisationMember") @patch("backend.graphene.middleware.Organisation") - def test_decision_cached_in_redis_across_requests(self, mock_org_cls): + def test_decision_cached_in_redis_across_requests( + self, mock_org_cls, mock_om_cls + ): """Second request against the same org must hit the Redis decision cache, not re-query Postgres — that's the whole point of Level 1 Redis caching.""" @@ -1689,6 +1696,7 @@ def test_decision_cached_in_redis_across_requests(self, mock_org_cls): org = MagicMock(require_sso=False) org.name = "acme" mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False mw = OrgSSOEnforcementMiddleware() @@ -1701,8 +1709,9 @@ def test_decision_cached_in_redis_across_requests(self, mock_org_cls): mock_org_cls.objects.only.return_value.get.call_count, 1 ) + @patch("backend.graphene.middleware.OrganisationMember") @patch("backend.graphene.middleware.Organisation") - def test_decision_invalidate_clears_redis(self, mock_org_cls): + def test_decision_invalidate_clears_redis(self, mock_org_cls, mock_om_cls): """invalidate_decision must drop the cache so the next request re-reads require_sso from the DB (so e.g. toggling enforcement takes effect immediately for other users, not after the 60s TTL).""" @@ -1711,6 +1720,7 @@ def test_decision_invalidate_clears_redis(self, mock_org_cls): org = MagicMock(require_sso=False) org.name = "acme" mock_org_cls.objects.only.return_value.get.return_value = org + mock_om_cls.objects.filter.return_value.exists.return_value = False mw = OrgSSOEnforcementMiddleware() diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 7d3f9d707..dfd4af326 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -72,11 +72,17 @@ type Documents = { "mutation BulkInviteMembers($orgId: ID!, $invites: [InviteInput!]!) {\n bulkInviteOrganisationMembers(orgId: $orgId, invites: $invites) {\n invites {\n id\n inviteeEmail\n expiresAt\n }\n }\n}": typeof types.BulkInviteMembersDocument, "mutation DeleteOrgInvite($inviteId: ID!) {\n deleteInvitation(inviteId: $inviteId) {\n ok\n }\n}": typeof types.DeleteOrgInviteDocument, "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}": typeof types.RemoveMemberDocument, + "mutation InitAccountKeys($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.InitAccountKeysDocument, "mutation TransferOrgOwnership($organisationId: ID!, $newOwnerId: ID!, $billingEmail: String) {\n transferOrganisationOwnership(\n organisationId: $organisationId\n newOwnerId: $newOwnerId\n billingEmail: $billingEmail\n ) {\n ok\n }\n}": typeof types.TransferOrgOwnershipDocument, "mutation UpdateMemberRole($memberId: ID!, $roleId: ID!) {\n updateOrganisationMemberRole(memberId: $memberId, roleId: $roleId) {\n orgMember {\n id\n role {\n name\n }\n }\n }\n}": typeof types.UpdateMemberRoleDocument, "mutation UpdateWrappedSecrets($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.UpdateWrappedSecretsDocument, "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}": typeof types.RotateAppKeyDocument, - "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}": typeof types.CreateServiceAccountOpDocument, + "mutation CreateSCIMTokenOp($organisationId: ID!, $name: String!, $expiryDays: Int) {\n createScimToken(\n organisationId: $organisationId\n name: $name\n expiryDays: $expiryDays\n ) {\n token\n scimToken {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n }\n }\n}": typeof types.CreateScimTokenOpDocument, + "mutation DeleteSCIMTokenOp($tokenId: ID!) {\n deleteScimToken(tokenId: $tokenId) {\n ok\n }\n}": typeof types.DeleteScimTokenOpDocument, + "mutation ToggleSCIMOp($organisationId: ID!, $enabled: Boolean!) {\n toggleScim(organisationId: $organisationId, enabled: $enabled) {\n ok\n }\n}": typeof types.ToggleScimOpDocument, + "mutation ToggleSCIMTokenOp($tokenId: ID!, $isActive: Boolean!) {\n toggleScimToken(tokenId: $tokenId, isActive: $isActive) {\n ok\n }\n}": typeof types.ToggleScimTokenOpDocument, + "mutation CreateServerSideSAToken($serviceAccountId: ID!, $name: String!, $expiry: BigInt) {\n createServerSideServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n expiry: $expiry\n ) {\n tokenString\n token {\n id\n }\n }\n}": typeof types.CreateServerSideSaTokenDocument, + "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String, $teamId: ID) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n teamId: $teamId\n ) {\n serviceAccount {\n id\n }\n }\n}": typeof types.CreateServiceAccountOpDocument, "mutation CreateSAToken($serviceAccountId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n token {\n id\n }\n }\n}": typeof types.CreateSaTokenDocument, "mutation DeleteServiceAccountOp($id: ID!) {\n deleteServiceAccount(serviceAccountId: $id) {\n ok\n }\n}": typeof types.DeleteServiceAccountOpDocument, "mutation DeleteServiceAccountTokenOp($id: ID!) {\n deleteServiceAccountToken(tokenId: $id) {\n ok\n }\n}": typeof types.DeleteServiceAccountTokenOpDocument, @@ -109,9 +115,19 @@ type Documents = { "mutation UpdateSyncAuth($syncId: ID!, $credentialId: ID!) {\n updateSyncAuthentication(syncId: $syncId, credentialId: $credentialId) {\n sync {\n id\n status\n }\n }\n}": typeof types.UpdateSyncAuthDocument, "mutation CreateNewVaultSync($envId: ID!, $path: String!, $engine: String!, $vaultPath: String!, $credentialId: ID!) {\n createVaultSync(\n envId: $envId\n path: $path\n engine: $engine\n vaultPath: $vaultPath\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewVaultSyncDocument, "mutation CreateNewVercelSync($envId: ID!, $path: String!, $credentialId: ID!, $projectId: String!, $projectName: String!, $teamId: String!, $teamName: String!, $environment: String!, $secretType: String!) {\n createVercelSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n projectId: $projectId\n projectName: $projectName\n teamId: $teamId\n teamName: $teamName\n environment: $environment\n secretType: $secretType\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewVercelSyncDocument, + "mutation AddTeamAppsOp($teamId: ID!, $appEnvs: [AppEnvironmentInput!]!) {\n addTeamApps(teamId: $teamId, appEnvs: $appEnvs) {\n team {\n id\n }\n }\n}": typeof types.AddTeamAppsOpDocument, + "mutation AddTeamMembersOp($teamId: ID!, $memberIds: [ID!]!, $memberType: MemberType) {\n addTeamMembers(teamId: $teamId, memberIds: $memberIds, memberType: $memberType) {\n team {\n id\n }\n }\n}": typeof types.AddTeamMembersOpDocument, + "mutation CreateTeamOp($organisationId: ID!, $name: String!, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n createTeam(\n organisationId: $organisationId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\n id\n name\n }\n }\n}": typeof types.CreateTeamOpDocument, + "mutation DeleteTeamOp($teamId: ID!) {\n deleteTeam(teamId: $teamId) {\n ok\n }\n}": typeof types.DeleteTeamOpDocument, + "mutation RemoveTeamAppOp($teamId: ID!, $appId: ID!) {\n removeTeamApp(teamId: $teamId, appId: $appId) {\n team {\n id\n }\n }\n}": typeof types.RemoveTeamAppOpDocument, + "mutation RemoveTeamMemberOp($teamId: ID!, $memberId: ID!, $memberType: MemberType) {\n removeTeamMember(teamId: $teamId, memberId: $memberId, memberType: $memberType) {\n team {\n id\n }\n }\n}": typeof types.RemoveTeamMemberOpDocument, + "mutation TransferTeamOwnershipOp($teamId: ID!, $newOwnerId: ID!) {\n transferTeamOwnership(teamId: $teamId, newOwnerId: $newOwnerId) {\n team {\n id\n }\n }\n}": typeof types.TransferTeamOwnershipOpDocument, + "mutation UpdateTeamOp($teamId: ID!, $name: String, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n updateTeam(\n teamId: $teamId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\n id\n name\n }\n }\n}": typeof types.UpdateTeamOpDocument, + "mutation UpdateTeamAppEnvironmentsOp($teamId: ID!, $appId: ID!, $envIds: [ID!]!) {\n updateTeamAppEnvironments(teamId: $teamId, appId: $appId, envIds: $envIds) {\n team {\n id\n }\n }\n}": typeof types.UpdateTeamAppEnvironmentsOpDocument, "mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}": typeof types.CreateNewUserTokenDocument, "mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}": typeof types.RevokeUserTokenDocument, "query GetIP {\n clientIp\n}": typeof types.GetIpDocument, + "query GetMemberEnvKeyGrants($appId: ID!, $memberId: ID!, $memberType: MemberType) {\n environmentKeys(appId: $appId, memberId: $memberId, memberType: $memberType) {\n id\n environment {\n id\n }\n grants {\n grantType\n team {\n id\n name\n }\n }\n }\n}": typeof types.GetMemberEnvKeyGrantsDocument, "query GetNetworkPolicies($organisationId: ID!) {\n networkAccessPolicies(organisationId: $organisationId) {\n id\n name\n allowedIps\n isGlobal\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n clientIp\n}": typeof types.GetNetworkPoliciesDocument, "query GetAppAccounts($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n }\n appServiceAccounts(appId: $appId) {\n id\n identityKey\n name\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n tokens {\n id\n name\n }\n }\n}": typeof types.GetAppAccountsDocument, "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n }\n}": typeof types.GetAppMembersDocument, @@ -126,7 +142,7 @@ type Documents = { "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n kmsLogs(appId: $appId, start: $start, end: $end) {\n logs {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n count\n }\n}": typeof types.GetAppKmsLogsDocument, "query GetApps($organisationId: ID!, $appId: ID) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n description\n identityKey\n createdAt\n updatedAt\n sseEnabled\n members {\n id\n email\n fullName\n avatarUrl\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n envType\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": typeof types.GetAppsDocument, "query GetDashboard($organisationId: ID!) {\n apps(organisationId: $organisationId) {\n id\n name\n sseEnabled\n }\n userTokens(organisationId: $organisationId) {\n id\n }\n organisationInvites(orgId: $organisationId) {\n id\n }\n organisationMembers(organisationId: $organisationId, role: null) {\n id\n }\n savedCredentials(orgId: $organisationId) {\n id\n }\n syncs(orgId: $organisationId) {\n id\n }\n}": typeof types.GetDashboardDocument, - "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}": typeof types.GetOrganisationsDocument, + "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n memberScimManaged\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n scimEnabled\n }\n}": typeof types.GetOrganisationsDocument, "query GetAwsStsEndpoints {\n awsStsEndpoints\n}": typeof types.GetAwsStsEndpointsDocument, "query GetIdentityProviders {\n identityProviders {\n id\n name\n description\n iconId\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 ... on AzureEntraConfigType {\n tenantId\n resource\n allowedServicePrincipalIds\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n createdAt\n }\n}": typeof types.GetOrganisationIdentitiesDocument, @@ -135,10 +151,12 @@ type 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}": 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 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 scimManaged\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 GetSCIMEvents($organisationId: ID!, $start: BigInt, $end: BigInt, $eventTypes: [String], $tokenId: ID, $status: String) {\n scimEvents(\n organisationId: $organisationId\n start: $start\n end: $end\n eventTypes: $eventTypes\n tokenId: $tokenId\n status: $status\n ) {\n events {\n id\n scimToken {\n id\n name\n }\n eventType\n status\n resourceType\n resourceId\n resourceName\n detail\n requestMethod\n requestPath\n requestBody\n responseStatus\n responseBody\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}": typeof types.GetScimEventsDocument, + "query GetSCIMTokens($organisationId: ID!) {\n scimTokens(organisationId: $organisationId) {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n isActive\n }\n}": typeof types.GetScimTokensDocument, "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, @@ -154,10 +172,10 @@ type Documents = { "query GetSecretTags($orgId: ID!) {\n secretTags(orgId: $orgId) {\n id\n name\n color\n }\n}": typeof types.GetSecretTagsDocument, "query GetSecrets($appId: ID!, $envId: ID!, $path: String) {\n secrets(envId: $envId, path: $path) {\n id\n key\n value\n path\n type\n tags {\n id\n name\n color\n }\n comment\n createdAt\n updatedAt\n override {\n value\n isActive\n }\n environment {\n id\n app {\n id\n }\n }\n }\n folders(envId: $envId, path: $path) {\n id\n name\n path\n createdAt\n folderCount\n secretCount\n }\n appEnvironments(appId: $appId, environmentId: $envId) {\n id\n name\n envType\n identityKey\n app {\n id\n name\n sseEnabled\n }\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n envSyncs(envId: $envId) {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n options\n isActive\n status\n lastSync\n createdAt\n }\n dynamicSecrets(envId: $envId, path: $path) {\n id\n name\n path\n description\n provider\n keyMap {\n id\n keyName\n masked\n }\n config {\n ... on AWSConfigType {\n usernameTemplate\n groups\n iamPath\n permissionBoundaryArn\n policyArns\n policyDocument\n }\n }\n defaultTtlSeconds\n maxTtlSeconds\n authentication {\n id\n name\n }\n createdAt\n }\n}": typeof types.GetSecretsDocument, "query GetServiceTokens($appId: ID!) {\n serviceTokens(appId: $appId) {\n id\n name\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n expiresAt\n keys {\n id\n identityKey\n }\n }\n}": typeof types.GetServiceTokensDocument, - "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}": typeof types.GetServiceAccountDetailDocument, + "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n team {\n id\n name\n memberRole {\n id\n name\n permissions\n }\n owner {\n id\n }\n members {\n orgMember {\n id\n }\n }\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}": typeof types.GetServiceAccountDetailDocument, "query GetServiceAccountHandlers($orgId: ID!) {\n serviceAccountHandlers(orgId: $orgId) {\n id\n email\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": typeof types.GetServiceAccountHandlersDocument, "query GetServiceAccountTokens($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n tokens {\n id\n name\n createdAt\n expiresAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n createdByServiceAccount {\n id\n name\n identityKey\n }\n lastUsed\n }\n }\n}": typeof types.GetServiceAccountTokensDocument, - "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}": typeof types.GetServiceAccountsDocument, + "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n team {\n id\n name\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}": typeof types.GetServiceAccountsDocument, "query GetOrgSSOProviders {\n organisations {\n id\n name\n requireSso\n ssoProviders {\n id\n providerType\n name\n publicConfig\n enabled\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n }\n serverPublicKey\n}": typeof types.GetOrgSsoProvidersDocument, "query GetOrganisationSyncs($orgId: ID!) {\n syncs(orgId: $orgId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n apps(organisationId: $orgId, appId: null) {\n id\n name\n identityKey\n createdAt\n sseEnabled\n members {\n id\n fullName\n avatarUrl\n email\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": typeof types.GetOrganisationSyncsDocument, "query GetAwsSecrets($credentialId: ID!) {\n awsSecrets(credentialId: $credentialId) {\n name\n arn\n }\n}": typeof types.GetAwsSecretsDocument, @@ -180,7 +198,8 @@ type Documents = { "query GetRenderResources($credentialId: ID!) {\n renderServices(credentialId: $credentialId) {\n id\n name\n type\n }\n renderEnvgroups(credentialId: $credentialId) {\n id\n name\n }\n}": typeof types.GetRenderResourcesDocument, "query TestVaultAuth($credentialId: ID!) {\n testVaultCreds(credentialId: $credentialId)\n}": typeof types.TestVaultAuthDocument, "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}": typeof types.GetVercelProjectsDocument, - "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": typeof types.GetOrganisationMemberDetailDocument, + "query GetTeams($organisationId: ID!, $teamId: ID) {\n teams(organisationId: $organisationId, teamId: $teamId) {\n id\n name\n description\n memberRole {\n id\n name\n description\n color\n permissions\n }\n serviceAccountRole {\n id\n name\n description\n color\n permissions\n }\n isScimManaged\n owner {\n id\n fullName\n email\n avatarUrl\n }\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n updatedAt\n memberCount\n members {\n id\n orgMember {\n id\n identityKey\n scimManaged\n email\n fullName\n avatarUrl\n role {\n id\n name\n description\n color\n permissions\n }\n }\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n color\n permissions\n }\n team {\n id\n name\n }\n }\n email\n fullName\n avatarUrl\n createdAt\n }\n apps {\n id\n name\n sseEnabled\n }\n appEnvironments {\n id\n app {\n id\n name\n }\n environment {\n id\n name\n envType\n }\n createdAt\n }\n }\n}": typeof types.GetTeamsDocument, + "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 scimManaged\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": typeof types.GetOrganisationMemberDetailDocument, "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": typeof types.GetUserTokensDocument, }; const documents: Documents = { @@ -242,11 +261,17 @@ const documents: Documents = { "mutation BulkInviteMembers($orgId: ID!, $invites: [InviteInput!]!) {\n bulkInviteOrganisationMembers(orgId: $orgId, invites: $invites) {\n invites {\n id\n inviteeEmail\n expiresAt\n }\n }\n}": types.BulkInviteMembersDocument, "mutation DeleteOrgInvite($inviteId: ID!) {\n deleteInvitation(inviteId: $inviteId) {\n ok\n }\n}": types.DeleteOrgInviteDocument, "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}": types.RemoveMemberDocument, + "mutation InitAccountKeys($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.InitAccountKeysDocument, "mutation TransferOrgOwnership($organisationId: ID!, $newOwnerId: ID!, $billingEmail: String) {\n transferOrganisationOwnership(\n organisationId: $organisationId\n newOwnerId: $newOwnerId\n billingEmail: $billingEmail\n ) {\n ok\n }\n}": types.TransferOrgOwnershipDocument, "mutation UpdateMemberRole($memberId: ID!, $roleId: ID!) {\n updateOrganisationMemberRole(memberId: $memberId, roleId: $roleId) {\n orgMember {\n id\n role {\n name\n }\n }\n }\n}": types.UpdateMemberRoleDocument, "mutation UpdateWrappedSecrets($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.UpdateWrappedSecretsDocument, "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}": types.RotateAppKeyDocument, - "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}": types.CreateServiceAccountOpDocument, + "mutation CreateSCIMTokenOp($organisationId: ID!, $name: String!, $expiryDays: Int) {\n createScimToken(\n organisationId: $organisationId\n name: $name\n expiryDays: $expiryDays\n ) {\n token\n scimToken {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n }\n }\n}": types.CreateScimTokenOpDocument, + "mutation DeleteSCIMTokenOp($tokenId: ID!) {\n deleteScimToken(tokenId: $tokenId) {\n ok\n }\n}": types.DeleteScimTokenOpDocument, + "mutation ToggleSCIMOp($organisationId: ID!, $enabled: Boolean!) {\n toggleScim(organisationId: $organisationId, enabled: $enabled) {\n ok\n }\n}": types.ToggleScimOpDocument, + "mutation ToggleSCIMTokenOp($tokenId: ID!, $isActive: Boolean!) {\n toggleScimToken(tokenId: $tokenId, isActive: $isActive) {\n ok\n }\n}": types.ToggleScimTokenOpDocument, + "mutation CreateServerSideSAToken($serviceAccountId: ID!, $name: String!, $expiry: BigInt) {\n createServerSideServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n expiry: $expiry\n ) {\n tokenString\n token {\n id\n }\n }\n}": types.CreateServerSideSaTokenDocument, + "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String, $teamId: ID) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n teamId: $teamId\n ) {\n serviceAccount {\n id\n }\n }\n}": types.CreateServiceAccountOpDocument, "mutation CreateSAToken($serviceAccountId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n token {\n id\n }\n }\n}": types.CreateSaTokenDocument, "mutation DeleteServiceAccountOp($id: ID!) {\n deleteServiceAccount(serviceAccountId: $id) {\n ok\n }\n}": types.DeleteServiceAccountOpDocument, "mutation DeleteServiceAccountTokenOp($id: ID!) {\n deleteServiceAccountToken(tokenId: $id) {\n ok\n }\n}": types.DeleteServiceAccountTokenOpDocument, @@ -279,9 +304,19 @@ const documents: Documents = { "mutation UpdateSyncAuth($syncId: ID!, $credentialId: ID!) {\n updateSyncAuthentication(syncId: $syncId, credentialId: $credentialId) {\n sync {\n id\n status\n }\n }\n}": types.UpdateSyncAuthDocument, "mutation CreateNewVaultSync($envId: ID!, $path: String!, $engine: String!, $vaultPath: String!, $credentialId: ID!) {\n createVaultSync(\n envId: $envId\n path: $path\n engine: $engine\n vaultPath: $vaultPath\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewVaultSyncDocument, "mutation CreateNewVercelSync($envId: ID!, $path: String!, $credentialId: ID!, $projectId: String!, $projectName: String!, $teamId: String!, $teamName: String!, $environment: String!, $secretType: String!) {\n createVercelSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n projectId: $projectId\n projectName: $projectName\n teamId: $teamId\n teamName: $teamName\n environment: $environment\n secretType: $secretType\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewVercelSyncDocument, + "mutation AddTeamAppsOp($teamId: ID!, $appEnvs: [AppEnvironmentInput!]!) {\n addTeamApps(teamId: $teamId, appEnvs: $appEnvs) {\n team {\n id\n }\n }\n}": types.AddTeamAppsOpDocument, + "mutation AddTeamMembersOp($teamId: ID!, $memberIds: [ID!]!, $memberType: MemberType) {\n addTeamMembers(teamId: $teamId, memberIds: $memberIds, memberType: $memberType) {\n team {\n id\n }\n }\n}": types.AddTeamMembersOpDocument, + "mutation CreateTeamOp($organisationId: ID!, $name: String!, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n createTeam(\n organisationId: $organisationId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\n id\n name\n }\n }\n}": types.CreateTeamOpDocument, + "mutation DeleteTeamOp($teamId: ID!) {\n deleteTeam(teamId: $teamId) {\n ok\n }\n}": types.DeleteTeamOpDocument, + "mutation RemoveTeamAppOp($teamId: ID!, $appId: ID!) {\n removeTeamApp(teamId: $teamId, appId: $appId) {\n team {\n id\n }\n }\n}": types.RemoveTeamAppOpDocument, + "mutation RemoveTeamMemberOp($teamId: ID!, $memberId: ID!, $memberType: MemberType) {\n removeTeamMember(teamId: $teamId, memberId: $memberId, memberType: $memberType) {\n team {\n id\n }\n }\n}": types.RemoveTeamMemberOpDocument, + "mutation TransferTeamOwnershipOp($teamId: ID!, $newOwnerId: ID!) {\n transferTeamOwnership(teamId: $teamId, newOwnerId: $newOwnerId) {\n team {\n id\n }\n }\n}": types.TransferTeamOwnershipOpDocument, + "mutation UpdateTeamOp($teamId: ID!, $name: String, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n updateTeam(\n teamId: $teamId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\n id\n name\n }\n }\n}": types.UpdateTeamOpDocument, + "mutation UpdateTeamAppEnvironmentsOp($teamId: ID!, $appId: ID!, $envIds: [ID!]!) {\n updateTeamAppEnvironments(teamId: $teamId, appId: $appId, envIds: $envIds) {\n team {\n id\n }\n }\n}": types.UpdateTeamAppEnvironmentsOpDocument, "mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}": types.CreateNewUserTokenDocument, "mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}": types.RevokeUserTokenDocument, "query GetIP {\n clientIp\n}": types.GetIpDocument, + "query GetMemberEnvKeyGrants($appId: ID!, $memberId: ID!, $memberType: MemberType) {\n environmentKeys(appId: $appId, memberId: $memberId, memberType: $memberType) {\n id\n environment {\n id\n }\n grants {\n grantType\n team {\n id\n name\n }\n }\n }\n}": types.GetMemberEnvKeyGrantsDocument, "query GetNetworkPolicies($organisationId: ID!) {\n networkAccessPolicies(organisationId: $organisationId) {\n id\n name\n allowedIps\n isGlobal\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n clientIp\n}": types.GetNetworkPoliciesDocument, "query GetAppAccounts($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n }\n appServiceAccounts(appId: $appId) {\n id\n identityKey\n name\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n tokens {\n id\n name\n }\n }\n}": types.GetAppAccountsDocument, "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n }\n}": types.GetAppMembersDocument, @@ -296,7 +331,7 @@ const documents: Documents = { "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n kmsLogs(appId: $appId, start: $start, end: $end) {\n logs {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n count\n }\n}": types.GetAppKmsLogsDocument, "query GetApps($organisationId: ID!, $appId: ID) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n description\n identityKey\n createdAt\n updatedAt\n sseEnabled\n members {\n id\n email\n fullName\n avatarUrl\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n envType\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": types.GetAppsDocument, "query GetDashboard($organisationId: ID!) {\n apps(organisationId: $organisationId) {\n id\n name\n sseEnabled\n }\n userTokens(organisationId: $organisationId) {\n id\n }\n organisationInvites(orgId: $organisationId) {\n id\n }\n organisationMembers(organisationId: $organisationId, role: null) {\n id\n }\n savedCredentials(orgId: $organisationId) {\n id\n }\n syncs(orgId: $organisationId) {\n id\n }\n}": types.GetDashboardDocument, - "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}": types.GetOrganisationsDocument, + "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n memberScimManaged\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n scimEnabled\n }\n}": types.GetOrganisationsDocument, "query GetAwsStsEndpoints {\n awsStsEndpoints\n}": types.GetAwsStsEndpointsDocument, "query GetIdentityProviders {\n identityProviders {\n id\n name\n description\n iconId\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 ... on AzureEntraConfigType {\n tenantId\n resource\n allowedServicePrincipalIds\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n createdAt\n }\n}": types.GetOrganisationIdentitiesDocument, @@ -305,10 +340,12 @@ const documents: 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}": 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 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 scimManaged\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 GetSCIMEvents($organisationId: ID!, $start: BigInt, $end: BigInt, $eventTypes: [String], $tokenId: ID, $status: String) {\n scimEvents(\n organisationId: $organisationId\n start: $start\n end: $end\n eventTypes: $eventTypes\n tokenId: $tokenId\n status: $status\n ) {\n events {\n id\n scimToken {\n id\n name\n }\n eventType\n status\n resourceType\n resourceId\n resourceName\n detail\n requestMethod\n requestPath\n requestBody\n responseStatus\n responseBody\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}": types.GetScimEventsDocument, + "query GetSCIMTokens($organisationId: ID!) {\n scimTokens(organisationId: $organisationId) {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n isActive\n }\n}": types.GetScimTokensDocument, "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, @@ -324,10 +361,10 @@ const documents: Documents = { "query GetSecretTags($orgId: ID!) {\n secretTags(orgId: $orgId) {\n id\n name\n color\n }\n}": types.GetSecretTagsDocument, "query GetSecrets($appId: ID!, $envId: ID!, $path: String) {\n secrets(envId: $envId, path: $path) {\n id\n key\n value\n path\n type\n tags {\n id\n name\n color\n }\n comment\n createdAt\n updatedAt\n override {\n value\n isActive\n }\n environment {\n id\n app {\n id\n }\n }\n }\n folders(envId: $envId, path: $path) {\n id\n name\n path\n createdAt\n folderCount\n secretCount\n }\n appEnvironments(appId: $appId, environmentId: $envId) {\n id\n name\n envType\n identityKey\n app {\n id\n name\n sseEnabled\n }\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n envSyncs(envId: $envId) {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n options\n isActive\n status\n lastSync\n createdAt\n }\n dynamicSecrets(envId: $envId, path: $path) {\n id\n name\n path\n description\n provider\n keyMap {\n id\n keyName\n masked\n }\n config {\n ... on AWSConfigType {\n usernameTemplate\n groups\n iamPath\n permissionBoundaryArn\n policyArns\n policyDocument\n }\n }\n defaultTtlSeconds\n maxTtlSeconds\n authentication {\n id\n name\n }\n createdAt\n }\n}": types.GetSecretsDocument, "query GetServiceTokens($appId: ID!) {\n serviceTokens(appId: $appId) {\n id\n name\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n expiresAt\n keys {\n id\n identityKey\n }\n }\n}": types.GetServiceTokensDocument, - "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}": types.GetServiceAccountDetailDocument, + "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n team {\n id\n name\n memberRole {\n id\n name\n permissions\n }\n owner {\n id\n }\n members {\n orgMember {\n id\n }\n }\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}": types.GetServiceAccountDetailDocument, "query GetServiceAccountHandlers($orgId: ID!) {\n serviceAccountHandlers(orgId: $orgId) {\n id\n email\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": types.GetServiceAccountHandlersDocument, "query GetServiceAccountTokens($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n tokens {\n id\n name\n createdAt\n expiresAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n createdByServiceAccount {\n id\n name\n identityKey\n }\n lastUsed\n }\n }\n}": types.GetServiceAccountTokensDocument, - "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}": types.GetServiceAccountsDocument, + "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n team {\n id\n name\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}": types.GetServiceAccountsDocument, "query GetOrgSSOProviders {\n organisations {\n id\n name\n requireSso\n ssoProviders {\n id\n providerType\n name\n publicConfig\n enabled\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n }\n serverPublicKey\n}": types.GetOrgSsoProvidersDocument, "query GetOrganisationSyncs($orgId: ID!) {\n syncs(orgId: $orgId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n apps(organisationId: $orgId, appId: null) {\n id\n name\n identityKey\n createdAt\n sseEnabled\n members {\n id\n fullName\n avatarUrl\n email\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": types.GetOrganisationSyncsDocument, "query GetAwsSecrets($credentialId: ID!) {\n awsSecrets(credentialId: $credentialId) {\n name\n arn\n }\n}": types.GetAwsSecretsDocument, @@ -350,7 +387,8 @@ const documents: Documents = { "query GetRenderResources($credentialId: ID!) {\n renderServices(credentialId: $credentialId) {\n id\n name\n type\n }\n renderEnvgroups(credentialId: $credentialId) {\n id\n name\n }\n}": types.GetRenderResourcesDocument, "query TestVaultAuth($credentialId: ID!) {\n testVaultCreds(credentialId: $credentialId)\n}": types.TestVaultAuthDocument, "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}": types.GetVercelProjectsDocument, - "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": types.GetOrganisationMemberDetailDocument, + "query GetTeams($organisationId: ID!, $teamId: ID) {\n teams(organisationId: $organisationId, teamId: $teamId) {\n id\n name\n description\n memberRole {\n id\n name\n description\n color\n permissions\n }\n serviceAccountRole {\n id\n name\n description\n color\n permissions\n }\n isScimManaged\n owner {\n id\n fullName\n email\n avatarUrl\n }\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n updatedAt\n memberCount\n members {\n id\n orgMember {\n id\n identityKey\n scimManaged\n email\n fullName\n avatarUrl\n role {\n id\n name\n description\n color\n permissions\n }\n }\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n color\n permissions\n }\n team {\n id\n name\n }\n }\n email\n fullName\n avatarUrl\n createdAt\n }\n apps {\n id\n name\n sseEnabled\n }\n appEnvironments {\n id\n app {\n id\n name\n }\n environment {\n id\n name\n envType\n }\n createdAt\n }\n }\n}": types.GetTeamsDocument, + "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 scimManaged\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": types.GetOrganisationMemberDetailDocument, "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": types.GetUserTokensDocument, }; @@ -600,6 +638,10 @@ export function graphql(source: "mutation DeleteOrgInvite($inviteId: ID!) {\n d * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}"): (typeof documents)["mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation InitAccountKeys($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation InitAccountKeys($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -619,7 +661,27 @@ export function graphql(source: "mutation RotateAppKey($id: ID!, $appToken: Stri /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}"): (typeof documents)["mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}"]; +export function graphql(source: "mutation CreateSCIMTokenOp($organisationId: ID!, $name: String!, $expiryDays: Int) {\n createScimToken(\n organisationId: $organisationId\n name: $name\n expiryDays: $expiryDays\n ) {\n token\n scimToken {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n }\n }\n}"): (typeof documents)["mutation CreateSCIMTokenOp($organisationId: ID!, $name: String!, $expiryDays: Int) {\n createScimToken(\n organisationId: $organisationId\n name: $name\n expiryDays: $expiryDays\n ) {\n token\n scimToken {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation DeleteSCIMTokenOp($tokenId: ID!) {\n deleteScimToken(tokenId: $tokenId) {\n ok\n }\n}"): (typeof documents)["mutation DeleteSCIMTokenOp($tokenId: ID!) {\n deleteScimToken(tokenId: $tokenId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation ToggleSCIMOp($organisationId: ID!, $enabled: Boolean!) {\n toggleScim(organisationId: $organisationId, enabled: $enabled) {\n ok\n }\n}"): (typeof documents)["mutation ToggleSCIMOp($organisationId: ID!, $enabled: Boolean!) {\n toggleScim(organisationId: $organisationId, enabled: $enabled) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation ToggleSCIMTokenOp($tokenId: ID!, $isActive: Boolean!) {\n toggleScimToken(tokenId: $tokenId, isActive: $isActive) {\n ok\n }\n}"): (typeof documents)["mutation ToggleSCIMTokenOp($tokenId: ID!, $isActive: Boolean!) {\n toggleScimToken(tokenId: $tokenId, isActive: $isActive) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateServerSideSAToken($serviceAccountId: ID!, $name: String!, $expiry: BigInt) {\n createServerSideServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n expiry: $expiry\n ) {\n tokenString\n token {\n id\n }\n }\n}"): (typeof documents)["mutation CreateServerSideSAToken($serviceAccountId: ID!, $name: String!, $expiry: BigInt) {\n createServerSideServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n expiry: $expiry\n ) {\n tokenString\n token {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String, $teamId: ID) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n teamId: $teamId\n ) {\n serviceAccount {\n id\n }\n }\n}"): (typeof documents)["mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String, $teamId: ID) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n teamId: $teamId\n ) {\n serviceAccount {\n id\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -748,6 +810,42 @@ export function graphql(source: "mutation CreateNewVaultSync($envId: ID!, $path: * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation CreateNewVercelSync($envId: ID!, $path: String!, $credentialId: ID!, $projectId: String!, $projectName: String!, $teamId: String!, $teamName: String!, $environment: String!, $secretType: String!) {\n createVercelSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n projectId: $projectId\n projectName: $projectName\n teamId: $teamId\n teamName: $teamName\n environment: $environment\n secretType: $secretType\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateNewVercelSync($envId: ID!, $path: String!, $credentialId: ID!, $projectId: String!, $projectName: String!, $teamId: String!, $teamName: String!, $environment: String!, $secretType: String!) {\n createVercelSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n projectId: $projectId\n projectName: $projectName\n teamId: $teamId\n teamName: $teamName\n environment: $environment\n secretType: $secretType\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation AddTeamAppsOp($teamId: ID!, $appEnvs: [AppEnvironmentInput!]!) {\n addTeamApps(teamId: $teamId, appEnvs: $appEnvs) {\n team {\n id\n }\n }\n}"): (typeof documents)["mutation AddTeamAppsOp($teamId: ID!, $appEnvs: [AppEnvironmentInput!]!) {\n addTeamApps(teamId: $teamId, appEnvs: $appEnvs) {\n team {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation AddTeamMembersOp($teamId: ID!, $memberIds: [ID!]!, $memberType: MemberType) {\n addTeamMembers(teamId: $teamId, memberIds: $memberIds, memberType: $memberType) {\n team {\n id\n }\n }\n}"): (typeof documents)["mutation AddTeamMembersOp($teamId: ID!, $memberIds: [ID!]!, $memberType: MemberType) {\n addTeamMembers(teamId: $teamId, memberIds: $memberIds, memberType: $memberType) {\n team {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateTeamOp($organisationId: ID!, $name: String!, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n createTeam(\n organisationId: $organisationId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\n id\n name\n }\n }\n}"): (typeof documents)["mutation CreateTeamOp($organisationId: ID!, $name: String!, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n createTeam(\n organisationId: $organisationId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\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. + */ +export function graphql(source: "mutation DeleteTeamOp($teamId: ID!) {\n deleteTeam(teamId: $teamId) {\n ok\n }\n}"): (typeof documents)["mutation DeleteTeamOp($teamId: ID!) {\n deleteTeam(teamId: $teamId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RemoveTeamAppOp($teamId: ID!, $appId: ID!) {\n removeTeamApp(teamId: $teamId, appId: $appId) {\n team {\n id\n }\n }\n}"): (typeof documents)["mutation RemoveTeamAppOp($teamId: ID!, $appId: ID!) {\n removeTeamApp(teamId: $teamId, appId: $appId) {\n team {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RemoveTeamMemberOp($teamId: ID!, $memberId: ID!, $memberType: MemberType) {\n removeTeamMember(teamId: $teamId, memberId: $memberId, memberType: $memberType) {\n team {\n id\n }\n }\n}"): (typeof documents)["mutation RemoveTeamMemberOp($teamId: ID!, $memberId: ID!, $memberType: MemberType) {\n removeTeamMember(teamId: $teamId, memberId: $memberId, memberType: $memberType) {\n team {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation TransferTeamOwnershipOp($teamId: ID!, $newOwnerId: ID!) {\n transferTeamOwnership(teamId: $teamId, newOwnerId: $newOwnerId) {\n team {\n id\n }\n }\n}"): (typeof documents)["mutation TransferTeamOwnershipOp($teamId: ID!, $newOwnerId: ID!) {\n transferTeamOwnership(teamId: $teamId, newOwnerId: $newOwnerId) {\n team {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateTeamOp($teamId: ID!, $name: String, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n updateTeam(\n teamId: $teamId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\n id\n name\n }\n }\n}"): (typeof documents)["mutation UpdateTeamOp($teamId: ID!, $name: String, $description: String, $memberRoleId: ID, $serviceAccountRoleId: ID) {\n updateTeam(\n teamId: $teamId\n name: $name\n description: $description\n memberRoleId: $memberRoleId\n serviceAccountRoleId: $serviceAccountRoleId\n ) {\n team {\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. + */ +export function graphql(source: "mutation UpdateTeamAppEnvironmentsOp($teamId: ID!, $appId: ID!, $envIds: [ID!]!) {\n updateTeamAppEnvironments(teamId: $teamId, appId: $appId, envIds: $envIds) {\n team {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateTeamAppEnvironmentsOp($teamId: ID!, $appId: ID!, $envIds: [ID!]!) {\n updateTeamAppEnvironments(teamId: $teamId, appId: $appId, envIds: $envIds) {\n team {\n id\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -760,6 +858,10 @@ export function graphql(source: "mutation RevokeUserToken($tokenId: ID!) {\n de * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query GetIP {\n clientIp\n}"): (typeof documents)["query GetIP {\n clientIp\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 GetMemberEnvKeyGrants($appId: ID!, $memberId: ID!, $memberType: MemberType) {\n environmentKeys(appId: $appId, memberId: $memberId, memberType: $memberType) {\n id\n environment {\n id\n }\n grants {\n grantType\n team {\n id\n name\n }\n }\n }\n}"): (typeof documents)["query GetMemberEnvKeyGrants($appId: ID!, $memberId: ID!, $memberType: MemberType) {\n environmentKeys(appId: $appId, memberId: $memberId, memberType: $memberType) {\n id\n environment {\n id\n }\n grants {\n grantType\n team {\n id\n name\n }\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -819,7 +921,7 @@ export function graphql(source: "query GetDashboard($organisationId: ID!) {\n a /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}"): (typeof documents)["query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}"]; +export function graphql(source: "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n memberScimManaged\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n scimEnabled\n }\n}"): (typeof documents)["query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n memberScimManaged\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n scimEnabled\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -855,7 +957,7 @@ export function graphql(source: "query GetOrgLicense($organisationId: ID!) {\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 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 documents)["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}"]; +export function graphql(source: "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 scimManaged\n }\n}"): (typeof documents)["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 scimManaged\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -868,6 +970,14 @@ 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}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetSCIMEvents($organisationId: ID!, $start: BigInt, $end: BigInt, $eventTypes: [String], $tokenId: ID, $status: String) {\n scimEvents(\n organisationId: $organisationId\n start: $start\n end: $end\n eventTypes: $eventTypes\n tokenId: $tokenId\n status: $status\n ) {\n events {\n id\n scimToken {\n id\n name\n }\n eventType\n status\n resourceType\n resourceId\n resourceName\n detail\n requestMethod\n requestPath\n requestBody\n responseStatus\n responseBody\n ipAddress\n userAgent\n timestamp\n }\n count\n }\n}"): (typeof documents)["query GetSCIMEvents($organisationId: ID!, $start: BigInt, $end: BigInt, $eventTypes: [String], $tokenId: ID, $status: String) {\n scimEvents(\n organisationId: $organisationId\n start: $start\n end: $end\n eventTypes: $eventTypes\n tokenId: $tokenId\n status: $status\n ) {\n events {\n id\n scimToken {\n id\n name\n }\n eventType\n status\n resourceType\n resourceId\n resourceName\n detail\n requestMethod\n requestPath\n requestBody\n responseStatus\n responseBody\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. + */ +export function graphql(source: "query GetSCIMTokens($organisationId: ID!) {\n scimTokens(organisationId: $organisationId) {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n isActive\n }\n}"): (typeof documents)["query GetSCIMTokens($organisationId: ID!) {\n scimTokens(organisationId: $organisationId) {\n id\n name\n tokenPrefix\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n expiresAt\n lastUsedAt\n isActive\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -931,7 +1041,7 @@ export function graphql(source: "query GetServiceTokens($appId: ID!) {\n servic /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}"): (typeof documents)["query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}"]; +export function graphql(source: "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n team {\n id\n name\n memberRole {\n id\n name\n permissions\n }\n owner {\n id\n }\n members {\n orgMember {\n id\n }\n }\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}"): (typeof documents)["query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n team {\n id\n name\n memberRole {\n id\n name\n permissions\n }\n owner {\n id\n }\n members {\n orgMember {\n id\n }\n }\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -943,7 +1053,7 @@ export function graphql(source: "query GetServiceAccountTokens($orgId: ID!, $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 GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}"): (typeof documents)["query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}"]; +export function graphql(source: "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n team {\n id\n name\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}"): (typeof documents)["query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n team {\n id\n name\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -1035,7 +1145,11 @@ export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\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 GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}"): (typeof documents)["query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}"]; +export function graphql(source: "query GetTeams($organisationId: ID!, $teamId: ID) {\n teams(organisationId: $organisationId, teamId: $teamId) {\n id\n name\n description\n memberRole {\n id\n name\n description\n color\n permissions\n }\n serviceAccountRole {\n id\n name\n description\n color\n permissions\n }\n isScimManaged\n owner {\n id\n fullName\n email\n avatarUrl\n }\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n updatedAt\n memberCount\n members {\n id\n orgMember {\n id\n identityKey\n scimManaged\n email\n fullName\n avatarUrl\n role {\n id\n name\n description\n color\n permissions\n }\n }\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n color\n permissions\n }\n team {\n id\n name\n }\n }\n email\n fullName\n avatarUrl\n createdAt\n }\n apps {\n id\n name\n sseEnabled\n }\n appEnvironments {\n id\n app {\n id\n name\n }\n environment {\n id\n name\n envType\n }\n createdAt\n }\n }\n}"): (typeof documents)["query GetTeams($organisationId: ID!, $teamId: ID) {\n teams(organisationId: $organisationId, teamId: $teamId) {\n id\n name\n description\n memberRole {\n id\n name\n description\n color\n permissions\n }\n serviceAccountRole {\n id\n name\n description\n color\n permissions\n }\n isScimManaged\n owner {\n id\n fullName\n email\n avatarUrl\n }\n createdBy {\n id\n fullName\n email\n avatarUrl\n }\n createdAt\n updatedAt\n memberCount\n members {\n id\n orgMember {\n id\n identityKey\n scimManaged\n email\n fullName\n avatarUrl\n role {\n id\n name\n description\n color\n permissions\n }\n }\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n color\n permissions\n }\n team {\n id\n name\n }\n }\n email\n fullName\n avatarUrl\n createdAt\n }\n apps {\n id\n name\n sseEnabled\n }\n appEnvironments {\n id\n app {\n id\n name\n }\n environment {\n id\n name\n envType\n }\n createdAt\n }\n }\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 GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 scimManaged\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}"): (typeof documents)["query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\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 scimManaged\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\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 9d6f58b1c..f3eec85f3 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -115,6 +115,16 @@ export type AddAppMemberMutation = { app?: Maybe; }; +export type AddTeamAppsMutation = { + __typename?: 'AddTeamAppsMutation'; + team?: Maybe; +}; + +export type AddTeamMembersMutation = { + __typename?: 'AddTeamMembersMutation'; + team?: Maybe; +}; + /** An enumeration. */ export enum ApiActivatedPhaseLicensePlanChoices { /** Enterprise */ @@ -171,6 +181,14 @@ export enum ApiEnvironmentEnvTypeChoices { Staging = 'STAGING' } +/** An enumeration. */ +export enum ApiEnvironmentKeyGrantGrantTypeChoices { + /** Individual */ + Individual = 'INDIVIDUAL', + /** Team */ + Team = 'TEAM' +} + /** An enumeration. */ export enum ApiEnvironmentSyncEventStatusChoices { /** cancelled */ @@ -217,6 +235,44 @@ export enum ApiOrganisationSsoProviderProviderTypeChoices { Okta = 'OKTA' } +/** An enumeration. */ +export enum ApiScimEventEventTypeChoices { + /** Group Created */ + GroupCreated = 'GROUP_CREATED', + /** Group Deleted */ + GroupDeleted = 'GROUP_DELETED', + /** Group Updated */ + GroupUpdated = 'GROUP_UPDATED', + /** Member Added to Group */ + MemberAdded = 'MEMBER_ADDED', + /** Member Removed from Group */ + MemberRemoved = 'MEMBER_REMOVED', + /** User Created */ + UserCreated = 'USER_CREATED', + /** User Deactivated */ + UserDeactivated = 'USER_DEACTIVATED', + /** User Reactivated */ + UserReactivated = 'USER_REACTIVATED', + /** User Updated */ + UserUpdated = 'USER_UPDATED' +} + +/** An enumeration. */ +export enum ApiScimEventResourceTypeChoices { + /** Group */ + Group = 'GROUP', + /** User */ + User = 'USER' +} + +/** An enumeration. */ +export enum ApiScimEventStatusChoices { + /** Error */ + Error = 'ERROR', + /** Success */ + Success = 'SUCCESS' +} + /** An enumeration. */ export enum ApiSecretEventEventTypeChoices { /** Create */ @@ -249,6 +305,11 @@ export enum ApiSecretTypeChoices { Secret = 'SECRET' } +export type AppEnvironmentInput = { + appId: Scalars['ID']['input']; + envIds: Array; +}; + export type AppMemberInputType = { envKeys: Array>; memberId: Scalars['ID']['input']; @@ -340,24 +401,8 @@ export type BulkInviteOrganisationMembersMutation = { }; /** - * Rotate the user's account password and rewrap the active org's - * keyring with the new deviceKey. Used by the in-session change-password - * dialog where the user supplies their current password, a new password, - * and the org's recovery mnemonic. - * - * Three server-side proofs are required: - * 1. current_auth_hash matches user.password — proves the caller - * knows the current login password. - * 2. identity_key matches the org's stored identity_key — proves the - * caller derived the keyring from the right mnemonic. - * 3. user is a member of the org. - * - * On success: user.password is set to new_auth_hash, the org's - * wrapped_keyring + wrapped_recovery are replaced, and the session is - * refreshed so the post-rotation HASH_SESSION_KEY stays valid. - * - * Only the active org's keyring is rewrapped. Other orgs the user - * belongs to remain encrypted with the old deviceKey; they'll fall + * Rotate user.password and rewrap the active org's keyring with + * the new deviceKey. Other orgs keep the old deviceKey and fall * through to per-org recovery on next access. */ export type ChangeAccountPasswordMutation = { @@ -505,6 +550,12 @@ export type CreateRenderSync = { sync?: Maybe; }; +export type CreateScimTokenMutation = { + __typename?: 'CreateSCIMTokenMutation'; + scimToken?: Maybe; + token?: Maybe; +}; + export type CreateSecretFolderMutation = { __typename?: 'CreateSecretFolderMutation'; folder?: Maybe; @@ -520,6 +571,19 @@ export type CreateSecretTagMutation = { tag?: Maybe; }; +/** + * Create a service account token using server-side key management. + * + * The server decrypts the SA keyring, generates a token with key splitting, + * and returns the full token string. Requires SSK to be enabled on the SA. + * This allows team members who are not SA handlers to create tokens. + */ +export type CreateServerSideServiceAccountTokenMutation = { + __typename?: 'CreateServerSideServiceAccountTokenMutation'; + token?: Maybe; + tokenString?: Maybe; +}; + export type CreateServiceAccountMutation = { __typename?: 'CreateServiceAccountMutation'; serviceAccount?: Maybe; @@ -545,6 +609,11 @@ export type CreateSubscriptionCheckoutSession = { clientSecret?: Maybe; }; +export type CreateTeamMutation = { + __typename?: 'CreateTeamMutation'; + team?: Maybe; +}; + export type CreateUserTokenMutation = { __typename?: 'CreateUserTokenMutation'; ok?: Maybe; @@ -621,6 +690,11 @@ export type DeleteProviderCredentials = { ok?: Maybe; }; +export type DeleteScimTokenMutation = { + __typename?: 'DeleteSCIMTokenMutation'; + ok?: Maybe; +}; + export type DeleteSecretFolderMutation = { __typename?: 'DeleteSecretFolderMutation'; ok?: Maybe; @@ -651,6 +725,11 @@ export type DeleteSync = { ok?: Maybe; }; +export type DeleteTeamMutation = { + __typename?: 'DeleteTeamMutation'; + ok?: Maybe; +}; + export type DeleteUserTokenMutation = { __typename?: 'DeleteUserTokenMutation'; ok?: Maybe; @@ -742,6 +821,15 @@ export type EnvironmentInput = { wrappedSeed: Scalars['String']['input']; }; +/** One key can carry multiple grants (individual + team). */ +export type EnvironmentKeyGrantType = { + __typename?: 'EnvironmentKeyGrantType'; + createdAt?: Maybe; + grantType: ApiEnvironmentKeyGrantGrantTypeChoices; + id: Scalars['String']['output']; + team?: Maybe; +}; + export type EnvironmentKeyInput = { envId: Scalars['ID']['input']; identityKey: Scalars['String']['input']; @@ -754,6 +842,7 @@ export type EnvironmentKeyType = { __typename?: 'EnvironmentKeyType'; createdAt?: Maybe; environment: EnvironmentType; + grants?: Maybe>; id: Scalars['String']['output']; identityKey: Scalars['String']['output']; updatedAt: Scalars['DateTime']['output']; @@ -1002,28 +1091,14 @@ export type MigratePricingMutation = { export type Mutation = { __typename?: 'Mutation'; addAppMember?: Maybe; + addTeamApps?: Maybe; + addTeamMembers?: Maybe; bulkAddAppMembers?: Maybe; bulkInviteOrganisationMembers?: Maybe; cancelSubscription?: Maybe; /** - * Rotate the user's account password and rewrap the active org's - * keyring with the new deviceKey. Used by the in-session change-password - * dialog where the user supplies their current password, a new password, - * and the org's recovery mnemonic. - * - * Three server-side proofs are required: - * 1. current_auth_hash matches user.password — proves the caller - * knows the current login password. - * 2. identity_key matches the org's stored identity_key — proves the - * caller derived the keyring from the right mnemonic. - * 3. user is a member of the org. - * - * On success: user.password is set to new_auth_hash, the org's - * wrapped_keyring + wrapped_recovery are replaced, and the session is - * refreshed so the post-rotation HASH_SESSION_KEY stays valid. - * - * Only the active org's keyring is rewrapped. Other orgs the user - * belongs to remain encrypted with the old deviceKey; they'll fall + * Rotate user.password and rewrap the active org's keyring with + * the new deviceKey. Other orgs keep the old deviceKey and fall * through to per-org recovery on next access. */ changeAccountPassword?: Maybe; @@ -1052,15 +1127,25 @@ export type Mutation = { createProviderCredentials?: Maybe; createRailwaySync?: Maybe; createRenderSync?: Maybe; + createScimToken?: Maybe; createSecret?: Maybe; createSecretFolder?: Maybe; createSecretTag?: Maybe; createSecrets?: Maybe; + /** + * Create a service account token using server-side key management. + * + * The server decrypts the SA keyring, generates a token with key splitting, + * and returns the full token string. Requires SSK to be enabled on the SA. + * This allows team members who are not SA handlers to create tokens. + */ + createServerSideServiceAccountToken?: Maybe; createServiceAccount?: Maybe; createServiceAccountToken?: Maybe; createServiceToken?: Maybe; createSetupIntent?: Maybe; createSubscriptionCheckoutSession?: Maybe; + createTeam?: Maybe; createUserToken?: Maybe; createVaultSync?: Maybe; createVercelSync?: Maybe; @@ -1076,12 +1161,14 @@ export type Mutation = { deleteOrganisationSsoProvider?: Maybe; deletePaymentMethod?: Maybe; deleteProviderCredentials?: Maybe; + deleteScimToken?: Maybe; deleteSecret?: Maybe; deleteSecretFolder?: Maybe; deleteSecrets?: Maybe; deleteServiceAccount?: Maybe; deleteServiceAccountToken?: Maybe; deleteServiceToken?: Maybe; + deleteTeam?: Maybe; deleteUserToken?: Maybe; editSecret?: Maybe; editSecrets?: Maybe; @@ -1092,25 +1179,17 @@ export type Mutation = { modifySubscription?: Maybe; readSecret?: Maybe; /** - * Rewrap THIS org's keyring with a deviceKey derived from the user's - * account password. Used by the recovery flow when the local keyring - * has been lost (cleared cache, new device) but the user still - * remembers their password. - * - * Two server-side proofs are required: - * 1. identity_key matches the org's stored identity_key — proves the - * caller derived the keyring from the right mnemonic. - * 2. auth_hash matches user.password — proves the password the user - * is wrapping the keyring with is also their account login auth. - * - * The mutation does NOT change user.password. The auth_hash check is a - * guardrail to keep auth and wrap passwords unified; if it fails, the - * user is trying to wrap the keyring with a password that doesn't - * authenticate them, which we never persist. + * Rewrap this member's keyring with a deviceKey derived from the + * user's account password. Used when the local keyring is lost + * (cleared cache, new device) but the user still has their password. + * Requires identity_key to match the member's stored value AND + * auth_hash to match user.password. Does not rotate user.password. */ recoverAccountKeyring?: Maybe; removeAppMember?: Maybe; removeOverride?: Maybe; + removeTeamApp?: Maybe; + removeTeamMember?: Maybe; renameEnvironment?: Maybe; renewDynamicSecretLease?: Maybe; resumeSubscription?: Maybe; @@ -1118,12 +1197,17 @@ export type Mutation = { rotateAppKeys?: Maybe; setDefaultPaymentMethod?: Maybe; testOrganisationSsoProvider?: Maybe; + /** Master switch: enable/disable SCIM for the organisation. */ + toggleScim?: Maybe; + /** Per-provider toggle: enable/disable a single SCIM token. */ + toggleScimToken?: Maybe; toggleSyncActive?: Maybe; /** * Transfer organisation ownership from the current owner to another member. * The new owner must have global access (Admin role) to ensure they have all necessary keys. */ transferOrganisationOwnership?: Maybe; + transferTeamOwnership?: Maybe; triggerSync?: Maybe; updateAccountNetworkAccessPolicies?: Maybe; updateAppInfo?: Maybe; @@ -1133,16 +1217,10 @@ export type Mutation = { updateIdentity?: Maybe; updateMemberEnvironmentScope?: Maybe; /** - * Re-wrap THIS org's keyring after the caller proves they hold the - * recovery mnemonic. Used by SSO recovery (where there's no login - * password to verify against, so identity is proven via the mnemonic - * alone). - * - * Requires identity_key matching the org's stored identity_key — proves - * the caller derived the keyring from the right mnemonic. Without this - * proof, an authenticated user (or session-cookie holder) could - * overwrite their own wrapped_keyring with arbitrary garbage and lock - * themselves out of the org permanently. + * Re-wrap this member's keyring (SSO recovery) or establish it on + * first-key ceremony (SCIM-preprovisioned members). Validates the + * supplied identity_key against the member's stored one, except on + * first ceremony when there's nothing yet to compare against. */ updateMemberWrappedSecrets?: Maybe; updateNetworkAccessPolicy?: Maybe; @@ -1153,6 +1231,8 @@ export type Mutation = { updateServiceAccount?: Maybe; updateServiceAccountHandlers?: Maybe; updateSyncAuthentication?: Maybe; + updateTeam?: Maybe; + updateTeamAppEnvironments?: Maybe; }; @@ -1164,6 +1244,19 @@ export type MutationAddAppMemberArgs = { }; +export type MutationAddTeamAppsArgs = { + appEnvs: Array; + teamId: Scalars['ID']['input']; +}; + + +export type MutationAddTeamMembersArgs = { + memberIds: Array; + memberType?: InputMaybe; + teamId: Scalars['ID']['input']; +}; + + export type MutationBulkAddAppMembersArgs = { appId: Scalars['ID']['input']; members: Array>; @@ -1430,6 +1523,13 @@ export type MutationCreateRenderSyncArgs = { }; +export type MutationCreateScimTokenArgs = { + expiryDays?: InputMaybe; + name: Scalars['String']['input']; + organisationId: Scalars['ID']['input']; +}; + + export type MutationCreateSecretArgs = { secretData?: InputMaybe; }; @@ -1454,6 +1554,13 @@ export type MutationCreateSecretsArgs = { }; +export type MutationCreateServerSideServiceAccountTokenArgs = { + expiry?: InputMaybe; + name: Scalars['String']['input']; + serviceAccountId: Scalars['ID']['input']; +}; + + export type MutationCreateServiceAccountArgs = { handlers?: InputMaybe>>; identityKey?: InputMaybe; @@ -1462,6 +1569,7 @@ export type MutationCreateServiceAccountArgs = { roleId?: InputMaybe; serverWrappedKeyring?: InputMaybe; serverWrappedRecovery?: InputMaybe; + teamId?: InputMaybe; }; @@ -1498,6 +1606,15 @@ export type MutationCreateSubscriptionCheckoutSessionArgs = { }; +export type MutationCreateTeamArgs = { + description?: InputMaybe; + memberRoleId?: InputMaybe; + name: Scalars['String']['input']; + organisationId: Scalars['ID']['input']; + serviceAccountRoleId?: InputMaybe; +}; + + export type MutationCreateUserTokenArgs = { expiry?: InputMaybe; identityKey: Scalars['String']['input']; @@ -1591,6 +1708,11 @@ export type MutationDeleteProviderCredentialsArgs = { }; +export type MutationDeleteScimTokenArgs = { + tokenId: Scalars['ID']['input']; +}; + + export type MutationDeleteSecretArgs = { id: Scalars['ID']['input']; }; @@ -1621,6 +1743,11 @@ export type MutationDeleteServiceTokenArgs = { }; +export type MutationDeleteTeamArgs = { + teamId: Scalars['ID']['input']; +}; + + export type MutationDeleteUserTokenArgs = { tokenId: Scalars['ID']['input']; }; @@ -1694,6 +1821,19 @@ export type MutationRemoveOverrideArgs = { }; +export type MutationRemoveTeamAppArgs = { + appId: Scalars['ID']['input']; + teamId: Scalars['ID']['input']; +}; + + +export type MutationRemoveTeamMemberArgs = { + memberId: Scalars['ID']['input']; + memberType?: InputMaybe; + teamId: Scalars['ID']['input']; +}; + + export type MutationRenameEnvironmentArgs = { environmentId: Scalars['ID']['input']; name: Scalars['String']['input']; @@ -1735,6 +1875,18 @@ export type MutationTestOrganisationSsoProviderArgs = { }; +export type MutationToggleScimArgs = { + enabled: Scalars['Boolean']['input']; + organisationId: Scalars['ID']['input']; +}; + + +export type MutationToggleScimTokenArgs = { + isActive: Scalars['Boolean']['input']; + tokenId: Scalars['ID']['input']; +}; + + export type MutationToggleSyncActiveArgs = { syncId?: InputMaybe; }; @@ -1747,6 +1899,12 @@ export type MutationTransferOrganisationOwnershipArgs = { }; +export type MutationTransferTeamOwnershipArgs = { + newOwnerId: Scalars['ID']['input']; + teamId: Scalars['ID']['input']; +}; + + export type MutationTriggerSyncArgs = { syncId?: InputMaybe; }; @@ -1876,6 +2034,22 @@ export type MutationUpdateSyncAuthenticationArgs = { syncId?: InputMaybe; }; + +export type MutationUpdateTeamArgs = { + description?: InputMaybe; + memberRoleId?: InputMaybe; + name?: InputMaybe; + serviceAccountRoleId?: InputMaybe; + teamId: Scalars['ID']['input']; +}; + + +export type MutationUpdateTeamAppEnvironmentsArgs = { + appId: Scalars['ID']['input']; + envIds: Array; + teamId: Scalars['ID']['input']; +}; + export type NamespaceType = { __typename?: 'NamespaceType'; fullPath?: Maybe; @@ -1933,6 +2107,7 @@ export type OrganisationMemberType = { lastLogin?: Maybe; networkPolicies?: Maybe>; role?: Maybe; + scimManaged?: Maybe; self?: Maybe; tokens?: Maybe>; updatedAt: Scalars['DateTime']['output']; @@ -1971,6 +2146,7 @@ export type OrganisationType = { identityKey: Scalars['String']['output']; keyring?: Maybe; memberId?: Maybe; + memberScimManaged?: Maybe; name: Scalars['String']['output']; plan: ApiOrganisationPlanChoices; planDetail?: Maybe; @@ -1978,6 +2154,7 @@ export type OrganisationType = { recovery?: Maybe; requireSso: Scalars['Boolean']['output']; role?: Maybe; + scimEnabled: Scalars['Boolean']['output']; ssoProviders?: Maybe>>; }; @@ -2099,6 +2276,8 @@ export type Query = { renderServices?: Maybe>>; roles?: Maybe>>; savedCredentials?: Maybe>>; + scimEvents?: Maybe; + scimTokens?: Maybe>>; secretHistory?: Maybe>>; secretLogs?: Maybe; secretTags?: Maybe>>; @@ -2113,6 +2292,7 @@ export type Query = { stripeCustomerPortalUrl?: Maybe; stripeSubscriptionDetails?: Maybe; syncs?: Maybe>>; + teams?: Maybe>>; testNomadCreds?: Maybe; testVaultCreds?: Maybe; testVercelCreds?: Maybe; @@ -2194,6 +2374,7 @@ export type QueryEnvironmentKeysArgs = { appId?: InputMaybe; environmentId?: InputMaybe; memberId?: InputMaybe; + memberType?: InputMaybe; }; @@ -2317,6 +2498,21 @@ export type QuerySavedCredentialsArgs = { }; +export type QueryScimEventsArgs = { + end?: InputMaybe; + eventTypes?: InputMaybe>>; + organisationId?: InputMaybe; + start?: InputMaybe; + status?: InputMaybe; + tokenId?: InputMaybe; +}; + + +export type QueryScimTokensArgs = { + organisationId?: InputMaybe; +}; + + export type QuerySecretHistoryArgs = { secretId?: InputMaybe; }; @@ -2388,6 +2584,12 @@ export type QuerySyncsArgs = { }; +export type QueryTeamsArgs = { + organisationId?: InputMaybe; + teamId?: InputMaybe; +}; + + export type QueryTestNomadCredsArgs = { credentialId?: InputMaybe; }; @@ -2461,21 +2663,11 @@ export type ReadSecretMutation = { }; /** - * Rewrap THIS org's keyring with a deviceKey derived from the user's - * account password. Used by the recovery flow when the local keyring - * has been lost (cleared cache, new device) but the user still - * remembers their password. - * - * Two server-side proofs are required: - * 1. identity_key matches the org's stored identity_key — proves the - * caller derived the keyring from the right mnemonic. - * 2. auth_hash matches user.password — proves the password the user - * is wrapping the keyring with is also their account login auth. - * - * The mutation does NOT change user.password. The auth_hash check is a - * guardrail to keep auth and wrap passwords unified; if it fails, the - * user is trying to wrap the keyring with a password that doesn't - * authenticate them, which we never persist. + * Rewrap this member's keyring with a deviceKey derived from the + * user's account password. Used when the local keyring is lost + * (cleared cache, new device) but the user still has their password. + * Requires identity_key to match the member's stored value AND + * auth_hash to match user.password. Does not rotate user.password. */ export type RecoverAccountKeyringMutation = { __typename?: 'RecoverAccountKeyringMutation'; @@ -2487,6 +2679,16 @@ export type RemoveAppMemberMutation = { app?: Maybe; }; +export type RemoveTeamAppMutation = { + __typename?: 'RemoveTeamAppMutation'; + team?: Maybe; +}; + +export type RemoveTeamMemberMutation = { + __typename?: 'RemoveTeamMemberMutation'; + team?: Maybe; +}; + export type RenameEnvironmentMutation = { __typename?: 'RenameEnvironmentMutation'; environment?: Maybe; @@ -2540,6 +2742,44 @@ export type RotateAppKeysMutation = { app?: Maybe; }; +export type ScimEventType = { + __typename?: 'SCIMEventType'; + detail: Scalars['JSONString']['output']; + eventType: ApiScimEventEventTypeChoices; + id: Scalars['String']['output']; + ipAddress?: Maybe; + requestBody?: Maybe; + requestMethod: Scalars['String']['output']; + requestPath: Scalars['String']['output']; + resourceId: Scalars['String']['output']; + resourceName: Scalars['String']['output']; + resourceType: ApiScimEventResourceTypeChoices; + responseBody?: Maybe; + responseStatus?: Maybe; + scimToken?: Maybe; + status: ApiScimEventStatusChoices; + timestamp: Scalars['DateTime']['output']; + userAgent?: Maybe; +}; + +export type ScimEventsResponseType = { + __typename?: 'SCIMEventsResponseType'; + count?: Maybe; + events?: Maybe>>; +}; + +export type ScimTokenType = { + __typename?: 'SCIMTokenType'; + createdAt?: Maybe; + createdBy?: Maybe; + expiresAt?: Maybe; + id: Scalars['String']['output']; + isActive: Scalars['Boolean']['output']; + lastUsedAt?: Maybe; + name: Scalars['String']['output']; + tokenPrefix: Scalars['String']['output']; +}; + export type SeatsUsed = { __typename?: 'SeatsUsed'; serviceAccounts?: Maybe; @@ -2684,6 +2924,7 @@ export type ServiceAccountType = { networkPolicies?: Maybe>; role?: Maybe; serverSideKeyManagementEnabled?: Maybe; + team?: Maybe; tokens?: Maybe>>; updatedAt: Scalars['DateTime']['output']; }; @@ -2750,6 +2991,43 @@ export type StripeSubscriptionDetails = { subscriptionId?: Maybe; }; +export type TeamAppEnvironmentType = { + __typename?: 'TeamAppEnvironmentType'; + app: AppMembershipType; + createdAt?: Maybe; + environment: EnvironmentType; + id: Scalars['String']['output']; +}; + +export type TeamMembershipType = { + __typename?: 'TeamMembershipType'; + avatarUrl?: Maybe; + createdAt?: Maybe; + email?: Maybe; + fullName?: Maybe; + id: Scalars['String']['output']; + orgMember?: Maybe; + serviceAccount?: Maybe; +}; + +export type TeamType = { + __typename?: 'TeamType'; + appEnvironments?: Maybe>; + apps?: Maybe>; + createdAt?: Maybe; + createdBy?: Maybe; + description?: Maybe; + id: Scalars['String']['output']; + isScimManaged: Scalars['Boolean']['output']; + memberCount?: Maybe; + memberRole?: Maybe; + members?: Maybe>; + name: Scalars['String']['output']; + owner?: Maybe; + serviceAccountRole?: Maybe; + updatedAt: Scalars['DateTime']['output']; +}; + export type TestOrganisationSsoProviderMutation = { __typename?: 'TestOrganisationSSOProviderMutation'; error?: Maybe; @@ -2765,6 +3043,18 @@ export enum TimeRange { Year = 'YEAR' } +/** Master switch: enable/disable SCIM for the organisation. */ +export type ToggleScimMutation = { + __typename?: 'ToggleSCIMMutation'; + ok?: Maybe; +}; + +/** Per-provider toggle: enable/disable a single SCIM token. */ +export type ToggleScimTokenMutation = { + __typename?: 'ToggleSCIMTokenMutation'; + ok?: Maybe; +}; + export type ToggleSyncActive = { __typename?: 'ToggleSyncActive'; ok?: Maybe; @@ -2779,6 +3069,11 @@ export type TransferOrganisationOwnershipMutation = { ok?: Maybe; }; +export type TransferTeamOwnershipMutation = { + __typename?: 'TransferTeamOwnershipMutation'; + team?: Maybe; +}; + export type TriggerSync = { __typename?: 'TriggerSync'; sync?: Maybe; @@ -2875,17 +3170,21 @@ export type UpdateSyncAuthentication = { sync?: Maybe; }; +export type UpdateTeamAppEnvironmentsMutation = { + __typename?: 'UpdateTeamAppEnvironmentsMutation'; + team?: Maybe; +}; + +export type UpdateTeamMutation = { + __typename?: 'UpdateTeamMutation'; + team?: Maybe; +}; + /** - * Re-wrap THIS org's keyring after the caller proves they hold the - * recovery mnemonic. Used by SSO recovery (where there's no login - * password to verify against, so identity is proven via the mnemonic - * alone). - * - * Requires identity_key matching the org's stored identity_key — proves - * the caller derived the keyring from the right mnemonic. Without this - * proof, an authenticated user (or session-cookie holder) could - * overwrite their own wrapped_keyring with arbitrary garbage and lock - * themselves out of the org permanently. + * Re-wrap this member's keyring (SSO recovery) or establish it on + * first-key ceremony (SCIM-preprovisioned members). Validates the + * supplied identity_key against the member's stored one, except on + * first ceremony when there's nothing yet to compare against. */ export type UpdateUserWrappedSecretsMutation = { __typename?: 'UpdateUserWrappedSecretsMutation'; @@ -3470,6 +3769,16 @@ export type RemoveMemberMutationVariables = Exact<{ export type RemoveMemberMutation = { __typename?: 'Mutation', deleteOrganisationMember?: { __typename?: 'DeleteOrganisationMemberMutation', ok?: boolean | null } | null }; +export type InitAccountKeysMutationVariables = Exact<{ + orgId: Scalars['ID']['input']; + identityKey: Scalars['String']['input']; + wrappedKeyring: Scalars['String']['input']; + wrappedRecovery: Scalars['String']['input']; +}>; + + +export type InitAccountKeysMutation = { __typename?: 'Mutation', updateMemberWrappedSecrets?: { __typename?: 'UpdateUserWrappedSecretsMutation', orgMember?: { __typename?: 'OrganisationMemberType', id: string } | null } | null }; + export type TransferOrgOwnershipMutationVariables = Exact<{ organisationId: Scalars['ID']['input']; newOwnerId: Scalars['ID']['input']; @@ -3506,6 +3815,47 @@ export type RotateAppKeyMutationVariables = Exact<{ export type RotateAppKeyMutation = { __typename?: 'Mutation', rotateAppKeys?: { __typename?: 'RotateAppKeysMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; +export type CreateScimTokenOpMutationVariables = Exact<{ + organisationId: Scalars['ID']['input']; + name: Scalars['String']['input']; + expiryDays?: InputMaybe; +}>; + + +export type CreateScimTokenOpMutation = { __typename?: 'Mutation', createScimToken?: { __typename?: 'CreateSCIMTokenMutation', token?: string | null, scimToken?: { __typename?: 'SCIMTokenType', id: string, name: string, tokenPrefix: string, createdAt?: any | null, expiresAt?: any | null, lastUsedAt?: any | null, createdBy?: { __typename?: 'OrganisationMemberType', id: string, fullName?: string | null, email?: string | null, avatarUrl?: string | null } | null } | null } | null }; + +export type DeleteScimTokenOpMutationVariables = Exact<{ + tokenId: Scalars['ID']['input']; +}>; + + +export type DeleteScimTokenOpMutation = { __typename?: 'Mutation', deleteScimToken?: { __typename?: 'DeleteSCIMTokenMutation', ok?: boolean | null } | null }; + +export type ToggleScimOpMutationVariables = Exact<{ + organisationId: Scalars['ID']['input']; + enabled: Scalars['Boolean']['input']; +}>; + + +export type ToggleScimOpMutation = { __typename?: 'Mutation', toggleScim?: { __typename?: 'ToggleSCIMMutation', ok?: boolean | null } | null }; + +export type ToggleScimTokenOpMutationVariables = Exact<{ + tokenId: Scalars['ID']['input']; + isActive: Scalars['Boolean']['input']; +}>; + + +export type ToggleScimTokenOpMutation = { __typename?: 'Mutation', toggleScimToken?: { __typename?: 'ToggleSCIMTokenMutation', ok?: boolean | null } | null }; + +export type CreateServerSideSaTokenMutationVariables = Exact<{ + serviceAccountId: Scalars['ID']['input']; + name: Scalars['String']['input']; + expiry?: InputMaybe; +}>; + + +export type CreateServerSideSaTokenMutation = { __typename?: 'Mutation', createServerSideServiceAccountToken?: { __typename?: 'CreateServerSideServiceAccountTokenMutation', tokenString?: string | null, token?: { __typename?: 'ServiceAccountTokenType', id: string } | null } | null }; + export type CreateServiceAccountOpMutationVariables = Exact<{ name: Scalars['String']['input']; orgId: Scalars['ID']['input']; @@ -3514,6 +3864,7 @@ export type CreateServiceAccountOpMutationVariables = Exact<{ handlers?: InputMaybe> | InputMaybe>; serverWrappedKeyring?: InputMaybe; serverWrappedRecovery?: InputMaybe; + teamId?: InputMaybe; }>; @@ -3832,6 +4183,86 @@ export type CreateNewVercelSyncMutationVariables = Exact<{ export type CreateNewVercelSyncMutation = { __typename?: 'Mutation', createVercelSync?: { __typename?: 'CreateVercelSync', sync?: { __typename?: 'EnvironmentSyncType', id: string, isActive: boolean, lastSync?: any | null, createdAt?: any | null, environment: { __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices }, serviceInfo?: { __typename?: 'ServiceType', id?: string | null, name?: string | null } | null } | null } | null }; +export type AddTeamAppsOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + appEnvs: Array | AppEnvironmentInput; +}>; + + +export type AddTeamAppsOpMutation = { __typename?: 'Mutation', addTeamApps?: { __typename?: 'AddTeamAppsMutation', team?: { __typename?: 'TeamType', id: string } | null } | null }; + +export type AddTeamMembersOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + memberIds: Array | Scalars['ID']['input']; + memberType?: InputMaybe; +}>; + + +export type AddTeamMembersOpMutation = { __typename?: 'Mutation', addTeamMembers?: { __typename?: 'AddTeamMembersMutation', team?: { __typename?: 'TeamType', id: string } | null } | null }; + +export type CreateTeamOpMutationVariables = Exact<{ + organisationId: Scalars['ID']['input']; + name: Scalars['String']['input']; + description?: InputMaybe; + memberRoleId?: InputMaybe; + serviceAccountRoleId?: InputMaybe; +}>; + + +export type CreateTeamOpMutation = { __typename?: 'Mutation', createTeam?: { __typename?: 'CreateTeamMutation', team?: { __typename?: 'TeamType', id: string, name: string } | null } | null }; + +export type DeleteTeamOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; +}>; + + +export type DeleteTeamOpMutation = { __typename?: 'Mutation', deleteTeam?: { __typename?: 'DeleteTeamMutation', ok?: boolean | null } | null }; + +export type RemoveTeamAppOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + appId: Scalars['ID']['input']; +}>; + + +export type RemoveTeamAppOpMutation = { __typename?: 'Mutation', removeTeamApp?: { __typename?: 'RemoveTeamAppMutation', team?: { __typename?: 'TeamType', id: string } | null } | null }; + +export type RemoveTeamMemberOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + memberId: Scalars['ID']['input']; + memberType?: InputMaybe; +}>; + + +export type RemoveTeamMemberOpMutation = { __typename?: 'Mutation', removeTeamMember?: { __typename?: 'RemoveTeamMemberMutation', team?: { __typename?: 'TeamType', id: string } | null } | null }; + +export type TransferTeamOwnershipOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + newOwnerId: Scalars['ID']['input']; +}>; + + +export type TransferTeamOwnershipOpMutation = { __typename?: 'Mutation', transferTeamOwnership?: { __typename?: 'TransferTeamOwnershipMutation', team?: { __typename?: 'TeamType', id: string } | null } | null }; + +export type UpdateTeamOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + name?: InputMaybe; + description?: InputMaybe; + memberRoleId?: InputMaybe; + serviceAccountRoleId?: InputMaybe; +}>; + + +export type UpdateTeamOpMutation = { __typename?: 'Mutation', updateTeam?: { __typename?: 'UpdateTeamMutation', team?: { __typename?: 'TeamType', id: string, name: string } | null } | null }; + +export type UpdateTeamAppEnvironmentsOpMutationVariables = Exact<{ + teamId: Scalars['ID']['input']; + appId: Scalars['ID']['input']; + envIds: Array | Scalars['ID']['input']; +}>; + + +export type UpdateTeamAppEnvironmentsOpMutation = { __typename?: 'Mutation', updateTeamAppEnvironments?: { __typename?: 'UpdateTeamAppEnvironmentsMutation', team?: { __typename?: 'TeamType', id: string } | null } | null }; + export type CreateNewUserTokenMutationVariables = Exact<{ orgId: Scalars['ID']['input']; name: Scalars['String']['input']; @@ -3856,6 +4287,15 @@ export type GetIpQueryVariables = Exact<{ [key: string]: never; }>; export type GetIpQuery = { __typename?: 'Query', clientIp?: string | null }; +export type GetMemberEnvKeyGrantsQueryVariables = Exact<{ + appId: Scalars['ID']['input']; + memberId: Scalars['ID']['input']; + memberType?: InputMaybe; +}>; + + +export type GetMemberEnvKeyGrantsQuery = { __typename?: 'Query', environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, environment: { __typename?: 'EnvironmentType', id: string }, grants?: Array<{ __typename?: 'EnvironmentKeyGrantType', grantType: ApiEnvironmentKeyGrantGrantTypeChoices, team?: { __typename?: 'TeamType', id: string, name: string } | null }> | null } | null> | null }; + export type GetNetworkPoliciesQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; }>; @@ -3965,7 +4405,7 @@ export type GetDashboardQuery = { __typename?: 'Query', apps?: Array<{ __typenam export type GetOrganisationsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetOrganisationsQuery = { __typename?: 'Query', organisations?: Array<{ __typename?: 'OrganisationType', id: string, name: string, identityKey: string, createdAt?: any | null, plan: ApiOrganisationPlanChoices, memberId?: string | null, keyring?: string | null, recovery?: string | null, pricingVersion: number, requireSso: boolean, planDetail?: { __typename?: 'OrganisationPlanType', name?: string | null, maxUsers?: number | null, maxApps?: number | null, maxEnvsPerApp?: number | null, appCount?: number | null, seatsUsed?: { __typename?: 'SeatsUsed', users?: number | null, serviceAccounts?: number | null, total?: number | null } | null } | null, role?: { __typename?: 'RoleType', name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, ssoProviders?: Array<{ __typename?: 'OrganisationSSOProviderType', name: string, providerType: ApiOrganisationSsoProviderProviderTypeChoices, enabled: boolean } | null> | null } | null> | null }; +export type GetOrganisationsQuery = { __typename?: 'Query', organisations?: Array<{ __typename?: 'OrganisationType', id: string, name: string, identityKey: string, createdAt?: any | null, plan: ApiOrganisationPlanChoices, memberId?: string | null, memberScimManaged?: boolean | null, keyring?: string | null, recovery?: string | null, pricingVersion: number, requireSso: boolean, scimEnabled: boolean, planDetail?: { __typename?: 'OrganisationPlanType', name?: string | null, maxUsers?: number | null, maxApps?: number | null, maxEnvsPerApp?: number | null, appCount?: number | null, seatsUsed?: { __typename?: 'SeatsUsed', users?: number | null, serviceAccounts?: number | null, total?: number | null } | null } | null, role?: { __typename?: 'RoleType', name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, ssoProviders?: Array<{ __typename?: 'OrganisationSSOProviderType', name: string, providerType: ApiOrganisationSsoProviderProviderTypeChoices, enabled: boolean } | null> | null } | null> | null }; export type GetAwsStsEndpointsQueryVariables = Exact<{ [key: string]: never; }>; @@ -4026,7 +4466,7 @@ export type GetOrganisationMembersQueryVariables = Exact<{ }>; -export type GetOrganisationMembersQuery = { __typename?: 'Query', organisationMembers?: Array<{ __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, lastLogin?: any | null, self?: boolean | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, permissions?: any | null, color?: string | null } | null } | null> | null }; +export type GetOrganisationMembersQuery = { __typename?: 'Query', organisationMembers?: Array<{ __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, lastLogin?: any | null, self?: boolean | null, scimManaged?: boolean | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, permissions?: any | null, color?: string | null } | null } | null> | null }; export type GetOrganisationPlanQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -4049,6 +4489,25 @@ 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 GetScimEventsQueryVariables = Exact<{ + organisationId: Scalars['ID']['input']; + start?: InputMaybe; + end?: InputMaybe; + eventTypes?: InputMaybe> | InputMaybe>; + tokenId?: InputMaybe; + status?: InputMaybe; +}>; + + +export type GetScimEventsQuery = { __typename?: 'Query', scimEvents?: { __typename?: 'SCIMEventsResponseType', count?: number | null, events?: Array<{ __typename?: 'SCIMEventType', id: string, eventType: ApiScimEventEventTypeChoices, status: ApiScimEventStatusChoices, resourceType: ApiScimEventResourceTypeChoices, resourceId: string, resourceName: string, detail: any, requestMethod: string, requestPath: string, requestBody?: any | null, responseStatus?: number | null, responseBody?: any | null, ipAddress?: string | null, userAgent?: string | null, timestamp: any, scimToken?: { __typename?: 'SCIMTokenType', id: string, name: string } | null } | null> | null } | null }; + +export type GetScimTokensQueryVariables = Exact<{ + organisationId: Scalars['ID']['input']; +}>; + + +export type GetScimTokensQuery = { __typename?: 'Query', scimTokens?: Array<{ __typename?: 'SCIMTokenType', id: string, name: string, tokenPrefix: string, createdAt?: any | null, expiresAt?: any | null, lastUsedAt?: any | null, isActive: boolean, createdBy?: { __typename?: 'OrganisationMemberType', id: string, fullName?: string | null, email?: string | null, avatarUrl?: string | null } | null } | null> | null }; + export type GetDynamicSecretsQueryVariables = Exact<{ orgId: Scalars['ID']['input']; appId?: InputMaybe; @@ -4179,7 +4638,7 @@ export type GetServiceAccountDetailQueryVariables = Exact<{ }>; -export type GetServiceAccountDetailQuery = { __typename?: 'Query', serviceAccounts?: Array<{ __typename?: 'ServiceAccountType', id: string, name: string, identityKey?: string | null, serverSideKeyManagementEnabled?: boolean | null, createdAt?: any | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, handlers?: Array<{ __typename?: 'ServiceAccountHandlerType', id: string, wrappedKeyring: string, wrappedRecovery: string, user: { __typename?: 'OrganisationMemberType', self?: boolean | null } } | null> | null, appMemberships?: Array<{ __typename?: 'AppMembershipType', id: string, name: string, sseEnabled: boolean, environments: Array<{ __typename?: 'EnvironmentType', id: string, name: string } | null> }> | null, networkPolicies?: Array<{ __typename?: 'NetworkAccessPolicyType', id: string, name: string, allowedIps: string, isGlobal: boolean }> | null, identities?: Array<{ __typename?: 'IdentityType', id: string, provider: string, name: string, description?: string | null }> | null } | null> | null }; +export type GetServiceAccountDetailQuery = { __typename?: 'Query', serviceAccounts?: Array<{ __typename?: 'ServiceAccountType', id: string, name: string, identityKey?: string | null, serverSideKeyManagementEnabled?: boolean | null, createdAt?: any | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, handlers?: Array<{ __typename?: 'ServiceAccountHandlerType', id: string, wrappedKeyring: string, wrappedRecovery: string, user: { __typename?: 'OrganisationMemberType', self?: boolean | null } } | null> | null, appMemberships?: Array<{ __typename?: 'AppMembershipType', id: string, name: string, sseEnabled: boolean, environments: Array<{ __typename?: 'EnvironmentType', id: string, name: string } | null> }> | null, networkPolicies?: Array<{ __typename?: 'NetworkAccessPolicyType', id: string, name: string, allowedIps: string, isGlobal: boolean }> | null, team?: { __typename?: 'TeamType', id: string, name: string, memberRole?: { __typename?: 'RoleType', id: string, name?: string | null, permissions?: any | null } | null, owner?: { __typename?: 'OrganisationMemberType', id: string } | null, members?: Array<{ __typename?: 'TeamMembershipType', orgMember?: { __typename?: 'OrganisationMemberType', id: string } | null }> | null } | null, identities?: Array<{ __typename?: 'IdentityType', id: string, provider: string, name: string, description?: string | null }> | null } | null> | null }; export type GetServiceAccountHandlersQueryVariables = Exact<{ orgId: Scalars['ID']['input']; @@ -4202,7 +4661,7 @@ export type GetServiceAccountsQueryVariables = Exact<{ }>; -export type GetServiceAccountsQuery = { __typename?: 'Query', serviceAccounts?: Array<{ __typename?: 'ServiceAccountType', id: string, name: string, identityKey?: string | null, createdAt?: any | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null } | null, handlers?: Array<{ __typename?: 'ServiceAccountHandlerType', id: string, wrappedKeyring: string, wrappedRecovery: string, user: { __typename?: 'OrganisationMemberType', self?: boolean | null } } | null> | null } | null> | null }; +export type GetServiceAccountsQuery = { __typename?: 'Query', serviceAccounts?: Array<{ __typename?: 'ServiceAccountType', id: string, name: string, identityKey?: string | null, createdAt?: any | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null } | null, team?: { __typename?: 'TeamType', id: string, name: string } | null, handlers?: Array<{ __typename?: 'ServiceAccountHandlerType', id: string, wrappedKeyring: string, wrappedRecovery: string, user: { __typename?: 'OrganisationMemberType', self?: boolean | null } } | null> | null } | null> | null }; export type GetOrgSsoProvidersQueryVariables = Exact<{ [key: string]: never; }>; @@ -4353,13 +4812,21 @@ export type GetVercelProjectsQueryVariables = Exact<{ export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environments?: Array<{ __typename?: 'VercelEnvironmentType', id: string, name: string, slug: string, type?: string | null } | null> | null } | null> | null } | null> | null }; +export type GetTeamsQueryVariables = Exact<{ + organisationId: Scalars['ID']['input']; + teamId?: InputMaybe; +}>; + + +export type GetTeamsQuery = { __typename?: 'Query', teams?: Array<{ __typename?: 'TeamType', id: string, name: string, description?: string | null, isScimManaged: boolean, createdAt?: any | null, updatedAt: any, memberCount?: number | null, memberRole?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, serviceAccountRole?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, owner?: { __typename?: 'OrganisationMemberType', id: string, fullName?: string | null, email?: string | null, avatarUrl?: string | null } | null, createdBy?: { __typename?: 'OrganisationMemberType', id: string, fullName?: string | null, email?: string | null, avatarUrl?: string | null } | null, members?: Array<{ __typename?: 'TeamMembershipType', id: string, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, orgMember?: { __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, scimManaged?: boolean | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null } | null, serviceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, team?: { __typename?: 'TeamType', id: string, name: string } | null } | null }> | null, apps?: Array<{ __typename?: 'AppType', id: string, name: string, sseEnabled: boolean }> | null, appEnvironments?: Array<{ __typename?: 'TeamAppEnvironmentType', id: string, createdAt?: any | null, app: { __typename?: 'AppMembershipType', id: string, name: string }, environment: { __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices } }> | null } | null> | null }; + export type GetOrganisationMemberDetailQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; id?: InputMaybe; }>; -export type GetOrganisationMemberDetailQuery = { __typename?: 'Query', organisationMembers?: Array<{ __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, lastLogin?: any | null, self?: boolean | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, permissions?: any | null, color?: string | null } | null, appMemberships?: Array<{ __typename?: 'AppMembershipType', id: string, name: string, sseEnabled: boolean, environments: Array<{ __typename?: 'EnvironmentType', id: string, name: string } | null> }> | null, tokens?: Array<{ __typename?: 'UserTokenType', id: string, name: string, createdAt?: any | null, expiresAt?: any | null }> | null, networkPolicies?: Array<{ __typename?: 'NetworkAccessPolicyType', id: string, name: string, allowedIps: string, isGlobal: boolean }> | null } | null> | null }; +export type GetOrganisationMemberDetailQuery = { __typename?: 'Query', organisationMembers?: Array<{ __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, lastLogin?: any | null, self?: boolean | null, scimManaged?: boolean | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, permissions?: any | null, color?: string | null } | null, appMemberships?: Array<{ __typename?: 'AppMembershipType', id: string, name: string, sseEnabled: boolean, environments: Array<{ __typename?: 'EnvironmentType', id: string, name: string } | null> }> | null, tokens?: Array<{ __typename?: 'UserTokenType', id: string, name: string, createdAt?: any | null, expiresAt?: any | null }> | null, networkPolicies?: Array<{ __typename?: 'NetworkAccessPolicyType', id: string, name: string, allowedIps: string, isGlobal: boolean }> | null } | null> | null }; export type GetUserTokensQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -4427,11 +4894,17 @@ export const AcceptOrganisationInviteDocument = {"kind":"Document","definitions" export const BulkInviteMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BulkInviteMembers"},"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":"invites"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bulkInviteOrganisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"invites"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invites"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteOrgInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOrgInvite"},"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":"deleteInvitation"},"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":"ok"}}]}}]}}]} as unknown as DocumentNode; export const RemoveMemberDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveMember"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOrganisationMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const InitAccountKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InitAccountKeys"},"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":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberWrappedSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const TransferOrgOwnershipDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TransferOrgOwnership"},"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":"newOwnerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingEmail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transferOrganisationOwnership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"newOwnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newOwnerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"billingEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingEmail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const UpdateMemberRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMemberRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrganisationMemberRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"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"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateWrappedSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWrappedSecrets"},"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":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberWrappedSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const RotateAppKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RotateAppKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rotateAppKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"appToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; -export const CreateServiceAccountOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccountOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountHandlerInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"handlers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateScimTokenOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSCIMTokenOp"},"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":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiryDays"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createScimToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiryDays"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiryDays"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"scimToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"tokenPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteScimTokenOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSCIMTokenOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteScimToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ToggleScimOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ToggleSCIMOp"},"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":"enabled"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"toggleScim"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"enabled"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const ToggleScimTokenOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ToggleSCIMTokenOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isActive"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"toggleScimToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}},{"kind":"Argument","name":{"kind":"Name","value":"isActive"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isActive"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const CreateServerSideSaTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServerSideSAToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServerSideServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokenString"}},{"kind":"Field","name":{"kind":"Name","value":"token"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateServiceAccountOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccountOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountHandlerInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"handlers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}}},{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateSaTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSAToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteServiceAccountOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteServiceAccountOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const DeleteServiceAccountTokenOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteServiceAccountTokenOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; @@ -4464,9 +4937,19 @@ export const UpdateProviderCredsDocument = {"kind":"Document","definitions":[{"k export const UpdateSyncAuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSyncAuth"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"syncId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSyncAuthentication"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"syncId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"syncId"}}},{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sync"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateNewVaultSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewVaultSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"engine"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"vaultPath"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createVaultSync"},"arguments":[{"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"}}},{"kind":"Argument","name":{"kind":"Name","value":"engine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"engine"}}},{"kind":"Argument","name":{"kind":"Name","value":"vaultPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"vaultPath"}}},{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sync"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateNewVercelSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewVercelSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"environment"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createVercelSync"},"arguments":[{"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"}}},{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}},{"kind":"Argument","name":{"kind":"Name","value":"projectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"projectName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectName"}}},{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"teamName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamName"}}},{"kind":"Argument","name":{"kind":"Name","value":"environment"},"value":{"kind":"Variable","name":{"kind":"Name","value":"environment"}}},{"kind":"Argument","name":{"kind":"Name","value":"secretType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sync"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const AddTeamAppsOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddTeamAppsOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appEnvs"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AppEnvironmentInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addTeamApps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appEnvs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appEnvs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const AddTeamMembersOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddTeamMembersOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"MemberType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addTeamMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateTeamOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateTeamOp"},"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":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"description"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberRoleId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountRoleId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"description"},"value":{"kind":"Variable","name":{"kind":"Name","value":"description"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberRoleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberRoleId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountRoleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountRoleId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteTeamOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteTeamOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const RemoveTeamAppOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveTeamAppOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeTeamApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const RemoveTeamMemberOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveTeamMemberOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"MemberType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeTeamMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const TransferTeamOwnershipOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TransferTeamOwnershipOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newOwnerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transferTeamOwnership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"newOwnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newOwnerId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateTeamOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateTeamOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"description"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberRoleId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountRoleId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"description"},"value":{"kind":"Variable","name":{"kind":"Name","value":"description"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberRoleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberRoleId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountRoleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountRoleId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateTeamAppEnvironmentsOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateTeamAppEnvironmentsOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTeamAppEnvironments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateNewUserTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewUserToken"},"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":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const RevokeUserTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeUserToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const GetIpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetIP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientIp"}}]}}]} as unknown as DocumentNode; +export const GetMemberEnvKeyGrantsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMemberEnvKeyGrants"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"MemberType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"grants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"grantType"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetNetworkPoliciesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetNetworkPolicies"},"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":"networkAccessPolicies"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clientIp"}}]}}]} as unknown as DocumentNode; export const GetAppAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppAccounts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"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":"appServiceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"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":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAppMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppMembers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"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"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -4481,7 +4964,7 @@ export const GetAppDetailDocument = {"kind":"Document","definitions":[{"kind":"O export const GetAppKmsLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppKmsLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"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"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kmsLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"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"}}}],"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":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"phaseNode"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"city"}},{"kind":"Field","name":{"kind":"Name","value":"phSize"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]} as unknown as DocumentNode; export const GetAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApps"},"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":"appId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"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":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"members"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"serviceAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetDashboardDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDashboard"},"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":"apps"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"userTokens"},"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":"organisationInvites"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"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":"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":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"savedCredentials"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"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":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const GetOrganisationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"planDetail"},"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":"appCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"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":"memberId"}},{"kind":"Field","name":{"kind":"Name","value":"keyring"}},{"kind":"Field","name":{"kind":"Name","value":"recovery"}},{"kind":"Field","name":{"kind":"Name","value":"pricingVersion"}},{"kind":"Field","name":{"kind":"Name","value":"requireSso"}},{"kind":"Field","name":{"kind":"Name","value":"ssoProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"providerType"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetOrganisationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"planDetail"},"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":"appCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"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":"memberId"}},{"kind":"Field","name":{"kind":"Name","value":"memberScimManaged"}},{"kind":"Field","name":{"kind":"Name","value":"keyring"}},{"kind":"Field","name":{"kind":"Name","value":"recovery"}},{"kind":"Field","name":{"kind":"Name","value":"pricingVersion"}},{"kind":"Field","name":{"kind":"Name","value":"requireSso"}},{"kind":"Field","name":{"kind":"Name","value":"ssoProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"providerType"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scimEnabled"}}]}}]}}]} as unknown as DocumentNode; export const GetAwsStsEndpointsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAwsStsEndpoints"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"awsStsEndpoints"}}]}}]} as unknown as DocumentNode; 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"}}]}}]}}]} 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":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AzureEntraConfigType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenantId"}},{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"allowedServicePrincipalIds"}}]}}]}},{"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; @@ -4490,10 +4973,12 @@ export const GetGlobalAccessUsersDocument = {"kind":"Document","definitions":[{" export const GetInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationInvites"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetLicenseDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLicenseData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"license"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"customerName"}},{"kind":"Field","name":{"kind":"Name","value":"organisationName"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"seats"}},{"kind":"Field","name":{"kind":"Name","value":"isActivated"}},{"kind":"Field","name":{"kind":"Name","value":"organisationOwner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]}}]} as unknown as DocumentNode; 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 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"}},{"kind":"Field","name":{"kind":"Name","value":"scimManaged"}}]}}]}}]} 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 GetScimEventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSCIMEvents"},"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":"eventTypes"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"status"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scimEvents"},"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":"eventTypes"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventTypes"}}},{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}},{"kind":"Argument","name":{"kind":"Name","value":"status"},"value":{"kind":"Variable","name":{"kind":"Name","value":"status"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"scimToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"resourceType"}},{"kind":"Field","name":{"kind":"Name","value":"resourceId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceName"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"requestMethod"}},{"kind":"Field","name":{"kind":"Name","value":"requestPath"}},{"kind":"Field","name":{"kind":"Name","value":"requestBody"}},{"kind":"Field","name":{"kind":"Name","value":"responseStatus"}},{"kind":"Field","name":{"kind":"Name","value":"responseBody"}},{"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 GetScimTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSCIMTokens"},"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":"scimTokens"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"tokenPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}}]}}]}}]} 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; @@ -4509,10 +4994,10 @@ export const GetEnvSecretsKvDocument = {"kind":"Document","definitions":[{"kind" export const GetSecretTagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSecretTags"},"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":"secretTags"},"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":"color"}}]}}]}}]} as unknown as DocumentNode; export const GetSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","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":"secrets"},"arguments":[{"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":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"override"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"folders"},"arguments":[{"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":"path"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"folderCount"}},{"kind":"Field","name":{"kind":"Name","value":"secretCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"appEnvironments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"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":"sseEnabled"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"environmentKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSeed"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSalt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"envSyncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"dynamicSecrets"},"arguments":[{"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":"path"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"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":"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":"groups"}},{"kind":"Field","name":{"kind":"Name","value":"iamPath"}},{"kind":"Field","name":{"kind":"Name","value":"permissionBoundaryArn"}},{"kind":"Field","name":{"kind":"Name","value":"policyArns"}},{"kind":"Field","name":{"kind":"Name","value":"policyDocument"}}]}}]}},{"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 GetServiceTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"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"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"keys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetServiceAccountDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccountDetail"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideKeyManagementEnabled"}},{"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"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"handlers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyring"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedRecovery"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identities"},"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"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetServiceAccountDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccountDetail"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideKeyManagementEnabled"}},{"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"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"handlers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyring"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedRecovery"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"memberRole"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"members"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"identities"},"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"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetServiceAccountHandlersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccountHandlers"},"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":"serviceAccountHandlers"},"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":"email"}},{"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 GetServiceAccountTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccountTokens"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdByServiceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastUsed"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccounts"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"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"}}]}},{"kind":"Field","name":{"kind":"Name","value":"handlers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyring"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedRecovery"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccounts"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"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"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"handlers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyring"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedRecovery"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; export const GetOrgSsoProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrgSSOProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"requireSso"}},{"kind":"Field","name":{"kind":"Name","value":"ssoProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"providerType"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publicConfig"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; export const GetOrganisationSyncsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationSyncs"},"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":"syncs"},"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":"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":"envType"}},{"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":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"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":"credentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"meta"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"savedCredentials"},"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":"credentials"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expectedCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"optionalCredentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"syncCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"members"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAwsSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAwsSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"awsSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"arn"}}]}}]}}]} as unknown as DocumentNode; @@ -4535,5 +5020,6 @@ export const GetRailwayProjectsDocument = {"kind":"Document","definitions":[{"ki export const GetRenderResourcesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRenderResources"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renderServices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"renderEnvgroups"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const TestVaultAuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TestVaultAuth"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testVaultCreds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}]}]}}]} as unknown as DocumentNode; export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetOrganisationMemberDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMemberDetail"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"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":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"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"}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetTeamsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTeams"},"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":"teamId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"teams"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"teamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"teamId"}}}],"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":"memberRole"},"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":"serviceAccountRole"},"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":"isScimManaged"}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"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":"createdBy"},"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":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"memberCount"}},{"kind":"Field","name":{"kind":"Name","value":"members"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"scimManaged"}},{"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":"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"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}}]}},{"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":"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"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"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":"apps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"appEnvironments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"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":"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":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetOrganisationMemberDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMemberDetail"},"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":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"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":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"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"}},{"kind":"Field","name":{"kind":"Name","value":"scimManaged"}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"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"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetUserTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserTokens"},"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":"userTokens"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyShare"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 831f3fd45..8b6fe6fa6 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -4,6 +4,9 @@ type Query { roles(orgId: ID): [RoleType] networkAccessPolicies(organisationId: ID): [NetworkAccessPolicyType] identities(organisationId: ID): [IdentityType] + teams(organisationId: ID, teamId: ID): [TeamType] + scimTokens(organisationId: ID): [SCIMTokenType] + scimEvents(organisationId: ID, start: BigInt, end: BigInt, eventTypes: [String], tokenId: ID, status: String): SCIMEventsResponseType organisationNameAvailable(name: String): Boolean verifyPassword(authHash: String!): Boolean license: PhaseLicenseType @@ -24,7 +27,7 @@ type Query { folders(envId: ID, path: String): [SecretFolderType] secretHistory(secretId: ID): [SecretEventType] secretTags(orgId: ID): [SecretTagType] - environmentKeys(appId: ID, environmentId: ID, memberId: ID): [EnvironmentKeyType] + environmentKeys(appId: ID, environmentId: ID, memberId: ID, memberType: MemberType): [EnvironmentKeyType] environmentTokens(environmentId: ID): [EnvironmentTokenType] userTokens(organisationId: ID): [UserTokenType] serviceTokens(appId: ID): [ServiceTokenType] @@ -73,8 +76,10 @@ type OrganisationType { plan: ApiOrganisationPlanChoices! pricingVersion: Int! requireSso: Boolean! + scimEnabled: Boolean! role: RoleType memberId: ID + memberScimManaged: Boolean keyring: String recovery: String planDetail: OrganisationPlanType @@ -167,6 +172,7 @@ type OrganisationMemberType { avatarUrl: String self: Boolean lastLogin: DateTime + scimManaged: Boolean appMemberships: [AppMembershipType!] tokens: [UserTokenType!] networkPolicies: [NetworkAccessPolicyType!] @@ -308,6 +314,7 @@ type ServiceAccountType { id: String! name: String! role: RoleType + team: TeamType identityKey: String createdAt: DateTime updatedAt: DateTime! @@ -320,6 +327,57 @@ type ServiceAccountType { identities: [IdentityType!] } +type TeamType { + id: String! + name: String! + description: String + memberRole: RoleType + serviceAccountRole: RoleType + isScimManaged: Boolean! + owner: OrganisationMemberType + createdBy: OrganisationMemberType + createdAt: DateTime + updatedAt: DateTime! + members: [TeamMembershipType!] + apps: [AppType!] + appEnvironments: [TeamAppEnvironmentType!] + memberCount: Int +} + +type TeamMembershipType { + id: String! + orgMember: OrganisationMemberType + serviceAccount: ServiceAccountType + createdAt: DateTime + email: String + fullName: String + avatarUrl: String +} + +type AppType { + id: String! + name: String! + description: String + identityKey: String! + appVersion: Int! + appToken: String! + appSeed: String! + wrappedKeyShare: String! + createdAt: DateTime + updatedAt: DateTime! + sseEnabled: Boolean! + serviceAccounts: [ServiceAccountType]! + environments: [EnvironmentType]! + members: [OrganisationMemberType]! +} + +type TeamAppEnvironmentType { + id: String! + app: AppMembershipType! + environment: EnvironmentType! + createdAt: DateTime +} + type ServiceAccountHandlerType { id: String! serviceAccount: ServiceAccountType! @@ -654,6 +712,96 @@ type UserTokenType { createdBy: OrganisationMemberType } +type SCIMTokenType { + id: String! + name: String! + tokenPrefix: String! + createdBy: OrganisationMemberType + createdAt: DateTime + expiresAt: DateTime + lastUsedAt: DateTime + isActive: Boolean! +} + +type SCIMEventsResponseType { + events: [SCIMEventType] + count: Int +} + +type SCIMEventType { + id: String! + scimToken: SCIMTokenType + eventType: ApiSCIMEventEventTypeChoices! + status: ApiSCIMEventStatusChoices! + resourceType: ApiSCIMEventResourceTypeChoices! + resourceId: String! + resourceName: String! + detail: JSONString! + requestMethod: String! + requestPath: String! + requestBody: JSONString + responseStatus: Int + responseBody: JSONString + ipAddress: String + userAgent: String + timestamp: DateTime! +} + +"""An enumeration.""" +enum ApiSCIMEventEventTypeChoices { + """User Created""" + USER_CREATED + + """User Updated""" + USER_UPDATED + + """User Deactivated""" + USER_DEACTIVATED + + """User Reactivated""" + USER_REACTIVATED + + """Group Created""" + GROUP_CREATED + + """Group Updated""" + GROUP_UPDATED + + """Group Deleted""" + GROUP_DELETED + + """Member Added to Group""" + MEMBER_ADDED + + """Member Removed from Group""" + MEMBER_REMOVED +} + +"""An enumeration.""" +enum ApiSCIMEventStatusChoices { + """Success""" + SUCCESS + + """Error""" + ERROR +} + +"""An enumeration.""" +enum ApiSCIMEventResourceTypeChoices { + """User""" + USER + + """Group""" + GROUP +} + +""" +The `BigInt` scalar type represents non-fractional whole numeric values. +`BigInt` is not constrained to 32-bit like the `Int` type and thus is a less +compatible type. +""" +scalar BigInt + type PhaseLicenseType { id: String customerName: String @@ -725,23 +873,6 @@ type OrganisationMemberInviteType { expiresAt: DateTime! } -type AppType { - id: String! - name: String! - description: String - identityKey: String! - appVersion: Int! - appToken: String! - appSeed: String! - wrappedKeyShare: String! - createdAt: DateTime - updatedAt: DateTime! - sseEnabled: Boolean! - serviceAccounts: [ServiceAccountType]! - environments: [EnvironmentType]! - members: [OrganisationMemberType]! -} - type KMSLogsResponseType { logs: [KMSLogType] count: Int @@ -770,13 +901,6 @@ interface Node { id: ID! } -""" -The `BigInt` scalar type represents non-fractional whole numeric values. -`BigInt` is not constrained to 32-bit like the `Int` type and thus is a less -compatible type. -""" -scalar BigInt - type SecretLogsResponseType { logs: [SecretEventType] count: Int @@ -810,6 +934,24 @@ type EnvironmentKeyType { wrappedSalt: String! createdAt: DateTime updatedAt: DateTime! + grants: [EnvironmentKeyGrantType!] +} + +"""One key can carry multiple grants (individual + team).""" +type EnvironmentKeyGrantType { + id: String! + grantType: ApiEnvironmentKeyGrantGrantTypeChoices! + team: TeamType + createdAt: DateTime +} + +"""An enumeration.""" +enum ApiEnvironmentKeyGrantGrantTypeChoices { + """Individual""" + INDIVIDUAL + + """Team""" + TEAM } type EnvironmentTokenType { @@ -1051,57 +1193,25 @@ type Mutation { transferOrganisationOwnership(billingEmail: String, newOwnerId: ID!, organisationId: ID!): TransferOrganisationOwnershipMutation """ - Re-wrap THIS org's keyring after the caller proves they hold the - recovery mnemonic. Used by SSO recovery (where there's no login - password to verify against, so identity is proven via the mnemonic - alone). - - Requires identity_key matching the org's stored identity_key — proves - the caller derived the keyring from the right mnemonic. Without this - proof, an authenticated user (or session-cookie holder) could - overwrite their own wrapped_keyring with arbitrary garbage and lock - themselves out of the org permanently. + Re-wrap this member's keyring (SSO recovery) or establish it on + first-key ceremony (SCIM-preprovisioned members). Validates the + supplied identity_key against the member's stored one, except on + first ceremony when there's nothing yet to compare against. """ updateMemberWrappedSecrets(identityKey: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): UpdateUserWrappedSecretsMutation """ - Rewrap THIS org's keyring with a deviceKey derived from the user's - account password. Used by the recovery flow when the local keyring - has been lost (cleared cache, new device) but the user still - remembers their password. - - Two server-side proofs are required: - 1. identity_key matches the org's stored identity_key — proves the - caller derived the keyring from the right mnemonic. - 2. auth_hash matches user.password — proves the password the user - is wrapping the keyring with is also their account login auth. - - The mutation does NOT change user.password. The auth_hash check is a - guardrail to keep auth and wrap passwords unified; if it fails, the - user is trying to wrap the keyring with a password that doesn't - authenticate them, which we never persist. + Rewrap this member's keyring with a deviceKey derived from the + user's account password. Used when the local keyring is lost + (cleared cache, new device) but the user still has their password. + Requires identity_key to match the member's stored value AND + auth_hash to match user.password. Does not rotate user.password. """ recoverAccountKeyring(authHash: String!, identityKey: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): RecoverAccountKeyringMutation """ - Rotate the user's account password and rewrap the active org's - keyring with the new deviceKey. Used by the in-session change-password - dialog where the user supplies their current password, a new password, - and the org's recovery mnemonic. - - Three server-side proofs are required: - 1. current_auth_hash matches user.password — proves the caller - knows the current login password. - 2. identity_key matches the org's stored identity_key — proves the - caller derived the keyring from the right mnemonic. - 3. user is a member of the org. - - On success: user.password is set to new_auth_hash, the org's - wrapped_keyring + wrapped_recovery are replaced, and the session is - refreshed so the post-rotation HASH_SESSION_KEY stays valid. - - Only the active org's keyring is rewrapped. Other orgs the user - belongs to remain encrypted with the old deviceKey; they'll fall + Rotate user.password and rewrap the active org's keyring with + the new deviceKey. Other orgs keep the old deviceKey and fall through to per-org recovery on next access. """ changeAccountPassword(currentAuthHash: String!, identityKey: String!, newAuthHash: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): ChangeAccountPasswordMutation @@ -1135,13 +1245,39 @@ type Mutation { deleteOrganisationSsoProvider(providerId: ID!): DeleteOrganisationSSOProviderMutation testOrganisationSsoProvider(providerId: ID!): TestOrganisationSSOProviderMutation updateOrganisationSecurity(orgId: ID!, requireSso: Boolean!): UpdateOrganisationSecurityMutation - createServiceAccount(handlers: [ServiceAccountHandlerInput], identityKey: String, name: String, organisationId: ID, roleId: ID, serverWrappedKeyring: String, serverWrappedRecovery: String): CreateServiceAccountMutation + createTeam(description: String, memberRoleId: ID, name: String!, organisationId: ID!, serviceAccountRoleId: ID): CreateTeamMutation + updateTeam(description: String, memberRoleId: ID, name: String, serviceAccountRoleId: ID, teamId: ID!): UpdateTeamMutation + transferTeamOwnership(newOwnerId: ID!, teamId: ID!): TransferTeamOwnershipMutation + deleteTeam(teamId: ID!): DeleteTeamMutation + addTeamMembers(memberIds: [ID!]!, memberType: MemberType = USER, teamId: ID!): AddTeamMembersMutation + removeTeamMember(memberId: ID!, memberType: MemberType = USER, teamId: ID!): RemoveTeamMemberMutation + addTeamApps(appEnvs: [AppEnvironmentInput!]!, teamId: ID!): AddTeamAppsMutation + removeTeamApp(appId: ID!, teamId: ID!): RemoveTeamAppMutation + updateTeamAppEnvironments(appId: ID!, envIds: [ID!]!, teamId: ID!): UpdateTeamAppEnvironmentsMutation + createScimToken(expiryDays: Int, name: String!, organisationId: ID!): CreateSCIMTokenMutation + deleteScimToken(tokenId: ID!): DeleteSCIMTokenMutation + + """Master switch: enable/disable SCIM for the organisation.""" + toggleScim(enabled: Boolean!, organisationId: ID!): ToggleSCIMMutation + + """Per-provider toggle: enable/disable a single SCIM token.""" + toggleScimToken(isActive: Boolean!, tokenId: ID!): ToggleSCIMTokenMutation + createServiceAccount(handlers: [ServiceAccountHandlerInput], identityKey: String, name: String, organisationId: ID, roleId: ID, serverWrappedKeyring: String, serverWrappedRecovery: String, teamId: ID): CreateServiceAccountMutation enableServiceAccountServerSideKeyManagement(serverWrappedKeyring: String, serverWrappedRecovery: String, serviceAccountId: ID): EnableServiceAccountServerSideKeyManagementMutation enableServiceAccountClientSideKeyManagement(serviceAccountId: ID): EnableServiceAccountClientSideKeyManagementMutation updateServiceAccountHandlers(handlers: [ServiceAccountHandlerInput], organisationId: ID): UpdateServiceAccountHandlersMutation updateServiceAccount(identityIds: [ID!], name: String, roleId: ID, serviceAccountId: ID): UpdateServiceAccountMutation deleteServiceAccount(serviceAccountId: ID): DeleteServiceAccountMutation createServiceAccountToken(expiry: BigInt, identityKey: String!, name: String!, serviceAccountId: ID, token: String!, wrappedKeyShare: String!): CreateServiceAccountTokenMutation + + """ + Create a service account token using server-side key management. + + The server decrypts the SA keyring, generates a token with key splitting, + and returns the full token string. Requires SSK to be enabled on the SA. + This allows team members who are not SA handlers to create tokens. + """ + createServerSideServiceAccountToken(expiry: BigInt, name: String!, serviceAccountId: ID!): CreateServerSideServiceAccountTokenMutation deleteServiceAccountToken(tokenId: ID): DeleteServiceAccountTokenMutation initEnvSync(appId: ID, envKeys: [EnvironmentKeyInput]): InitEnvSync deleteEnvSync(syncId: ID): DeleteSync @@ -1236,61 +1372,29 @@ type TransferOrganisationOwnershipMutation { } """ -Re-wrap THIS org's keyring after the caller proves they hold the -recovery mnemonic. Used by SSO recovery (where there's no login -password to verify against, so identity is proven via the mnemonic -alone). - -Requires identity_key matching the org's stored identity_key — proves -the caller derived the keyring from the right mnemonic. Without this -proof, an authenticated user (or session-cookie holder) could -overwrite their own wrapped_keyring with arbitrary garbage and lock -themselves out of the org permanently. +Re-wrap this member's keyring (SSO recovery) or establish it on +first-key ceremony (SCIM-preprovisioned members). Validates the +supplied identity_key against the member's stored one, except on +first ceremony when there's nothing yet to compare against. """ type UpdateUserWrappedSecretsMutation { orgMember: OrganisationMemberType } """ -Rewrap THIS org's keyring with a deviceKey derived from the user's -account password. Used by the recovery flow when the local keyring -has been lost (cleared cache, new device) but the user still -remembers their password. - -Two server-side proofs are required: - 1. identity_key matches the org's stored identity_key — proves the - caller derived the keyring from the right mnemonic. - 2. auth_hash matches user.password — proves the password the user - is wrapping the keyring with is also their account login auth. - -The mutation does NOT change user.password. The auth_hash check is a -guardrail to keep auth and wrap passwords unified; if it fails, the -user is trying to wrap the keyring with a password that doesn't -authenticate them, which we never persist. +Rewrap this member's keyring with a deviceKey derived from the +user's account password. Used when the local keyring is lost +(cleared cache, new device) but the user still has their password. +Requires identity_key to match the member's stored value AND +auth_hash to match user.password. Does not rotate user.password. """ type RecoverAccountKeyringMutation { orgMember: OrganisationMemberType } """ -Rotate the user's account password and rewrap the active org's -keyring with the new deviceKey. Used by the in-session change-password -dialog where the user supplies their current password, a new password, -and the org's recovery mnemonic. - -Three server-side proofs are required: - 1. current_auth_hash matches user.password — proves the caller - knows the current login password. - 2. identity_key matches the org's stored identity_key — proves the - caller derived the keyring from the right mnemonic. - 3. user is a member of the org. - -On success: user.password is set to new_auth_hash, the org's -wrapped_keyring + wrapped_recovery are replaced, and the session is -refreshed so the post-rotation HASH_SESSION_KEY stays valid. - -Only the active org's keyring is rewrapped. Other orgs the user -belongs to remain encrypted with the old deviceKey; they'll fall +Rotate user.password and rewrap the active org's keyring with +the new deviceKey. Other orgs keep the old deviceKey and fall through to per-org recovery on next access. """ type ChangeAccountPasswordMutation { @@ -1460,6 +1564,66 @@ type UpdateOrganisationSecurityMutation { sessionInvalidated: Boolean } +type CreateTeamMutation { + team: TeamType +} + +type UpdateTeamMutation { + team: TeamType +} + +type TransferTeamOwnershipMutation { + team: TeamType +} + +type DeleteTeamMutation { + ok: Boolean +} + +type AddTeamMembersMutation { + team: TeamType +} + +type RemoveTeamMemberMutation { + team: TeamType +} + +type AddTeamAppsMutation { + team: TeamType +} + +input AppEnvironmentInput { + appId: ID! + envIds: [ID!]! +} + +type RemoveTeamAppMutation { + team: TeamType +} + +type UpdateTeamAppEnvironmentsMutation { + team: TeamType +} + +type CreateSCIMTokenMutation { + token: String + scimToken: SCIMTokenType +} + +type DeleteSCIMTokenMutation { + ok: Boolean +} + +"""Master switch: enable/disable SCIM for the organisation.""" +type ToggleSCIMMutation { + ok: Boolean +} + +"""Per-provider toggle: enable/disable a single SCIM token.""" +type ToggleSCIMTokenMutation { + ok: Boolean +} + type CreateServiceAccountMutation { serviceAccount: ServiceAccountType } @@ -1495,6 +1659,18 @@ type CreateServiceAccountTokenMutation { token: ServiceAccountTokenType } +""" +Create a service account token using server-side key management. + +The server decrypts the SA keyring, generates a token with key splitting, +and returns the full token string. Requires SSK to be enabled on the SA. +This allows team members who are not SA handlers to create tokens. +""" +type CreateServerSideServiceAccountTokenMutation { + tokenString: String + token: ServiceAccountTokenType +} + type DeleteServiceAccountTokenMutation { ok: Boolean } diff --git a/frontend/app/[team]/access/layout.tsx b/frontend/app/[team]/access/layout.tsx index 8d52ca9a6..4c4c74ee9 100644 --- a/frontend/app/[team]/access/layout.tsx +++ b/frontend/app/[team]/access/layout.tsx @@ -27,6 +27,10 @@ export default function AccessLayout({ name: 'Service Accounts', link: 'service-accounts', }, + { + name: 'Teams', + link: 'teams', + }, { name: 'Roles', link: 'roles', @@ -43,6 +47,10 @@ export default function AccessLayout({ name: 'Authentication', link: 'authentication', }, + { + name: 'SCIM', + link: 'scim', + }, { name: 'Network', link: 'network', diff --git a/frontend/app/[team]/access/members/[memberId]/page.tsx b/frontend/app/[team]/access/members/[memberId]/page.tsx index e6c0aa7ac..1bca722a9 100644 --- a/frontend/app/[team]/access/members/[memberId]/page.tsx +++ b/frontend/app/[team]/access/members/[memberId]/page.tsx @@ -3,14 +3,21 @@ import Spinner from '@/components/common/Spinner' import { organisationContext } from '@/contexts/organisationContext' import { GetOrganisationMemberDetail } from '@/graphql/queries/users/getOrganisationMemberDetail.gql' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' import { userHasPermission } from '@/utils/access/permissions' import { useQuery } from '@apollo/client' import Link from 'next/link' -import { useContext } from 'react' -import { FaBan, FaChevronLeft, FaClock, FaCog, FaKey, FaNetworkWired } from 'react-icons/fa' +import { useContext, useMemo } from 'react' +import { FaBan, FaChevronLeft, FaClock, FaCog, FaExclamationTriangle, FaKey, FaNetworkWired } from 'react-icons/fa' import { Avatar } from '@/components/common/Avatar' import { EmptyState } from '@/components/common/EmptyState' -import { OrganisationMemberType, UserTokenType, AppMembershipType } from '@/apollo/graphql' +import { + OrganisationMemberType, + UserTokenType, + AppMembershipType, + TeamType, +} from '@/apollo/graphql' +import { RoleLabel } from '@/components/users/RoleLabel' import { DeleteMemberConfirmDialog } from '../_components/DeleteMemberConfirmDialog' import { RoleSelector } from '../_components/RoleSelector' import { relativeTimeFromDates } from '@/utils/time' @@ -59,6 +66,10 @@ export default function MemberDetail({ params }: { params: { team: string; membe ? userHasPermission(organisation.role!.permissions, 'MemberPersonalAccessTokens', 'delete') : false + const userCanReadTeams = organisation + ? userHasPermission(organisation.role!.permissions, 'Teams', 'read') + : false + const { data, loading, error } = useQuery(GetOrganisationMemberDetail, { variables: { organisationId: organisation?.id, @@ -68,8 +79,20 @@ export default function MemberDetail({ params }: { params: { team: string; membe fetchPolicy: 'cache-and-network', }) + const { data: teamsData } = useQuery(GetTeams, { + variables: { organisationId: organisation?.id }, + skip: !organisation || !userCanReadTeams, + }) + const member: OrganisationMemberType | undefined = data?.organisationMembers[0] + const memberTeams = useMemo(() => { + if (!teamsData?.teams || !member) return [] + return (teamsData.teams as TeamType[]).filter((team) => + team.members?.some((m) => m.orgMember?.id === member.id) + ) + }, [teamsData, member]) + if (loading || !organisation) { return (
@@ -152,10 +175,22 @@ export default function MemberDetail({ params }: { params: { team: string; membe
-

- {member.fullName || 'User'} {member.self && ' (You)'}{' '} -

+
+

+ {member.fullName || 'User'} {member.self && ' (You)'} +

+ {member.scimManaged && ( + + SCIM + + )} +
{member.email} + {!member.identityKey && ( + + Key ceremony pending — user has not logged in yet + + )} {member.lastLogin ? (
+ {userCanReadTeams && ( +
+
+
Teams
+
Teams this member belongs to
+
+ +
+ {memberTeams.length > 0 ? ( + memberTeams.map((team) => ( +
+
+
+ + {team.name} + + {team.memberRole && } +
+ {team.description && ( +
+ {team.description} +
+ )} +
+ {team.apps?.length || 0} app{team.apps?.length !== 1 ? 's' : ''} + {team.apps && team.apps.length > 0 && ( + <> ({team.apps.map((a) => a!.name).join(', ')}) + )} +
+
+ + + +
+ )) + ) : ( +
+ This member is not part of any teams. +
+ )} +
+
+ )} + {userCanReadAppMemberships && (
App Access
- Apps and Environments this member has access to + Apps and Environments this member has direct access to
- {userCanWriteAppMemberships && !member.self && ( + {userCanWriteAppMemberships && !member.self && member.identityKey && ( + This member is managed by SCIM and cannot be removed from the console. Deprovision them + from your identity provider. +

+ ) + } if (!allowDelete) return <> diff --git a/frontend/app/[team]/access/members/_components/RoleSelector.tsx b/frontend/app/[team]/access/members/_components/RoleSelector.tsx index 053696671..4cb3e74d4 100644 --- a/frontend/app/[team]/access/members/_components/RoleSelector.tsx +++ b/frontend/app/[team]/access/members/_components/RoleSelector.tsx @@ -5,10 +5,10 @@ import { Fragment, useContext, useEffect, useState } from 'react' import { OrganisationMemberType, AppType, EnvironmentType, RoleType } from '@/apollo/graphql' import { organisationContext } from '@/contexts/organisationContext' import { Listbox, Transition } from '@headlessui/react' -import { FaChevronDown } from 'react-icons/fa' +import { FaChevronDown, FaExclamationTriangle } from 'react-icons/fa' import clsx from 'clsx' import { toast } from 'react-toastify' -import { PermissionPolicy, userHasGlobalAccess } from '@/utils/access/permissions' +import { PermissionPolicy, isRoleCryptoSafe, userHasGlobalAccess } from '@/utils/access/permissions' import { RoleLabel } from '@/components/users/RoleLabel' import { KeyringContext } from '@/contexts/keyringContext' import { unwrapEnvSecretsForUser, wrapEnvSecretsForAccount } from '@/utils/crypto' @@ -249,6 +249,12 @@ export const RoleSelector = (props: { const roleOptions = roleData?.roles.filter((option: RoleType) => option.name?.toLowerCase() !== 'owner') || [] + // Members without a completed key ceremony can only hold crypto-safe + // roles — the backend rejects anything that would make them an SA + // handler. Surface this in the dropdown so admins don't pick a role + // and then bounce off a backend error toast. + const memberKeyCeremonyPending = !member.identityKey + // Determine if the selector should be disabled const disabled = !!(displayOnly || isOwner || !userCanUpdateMemberRoles || member.self) @@ -271,9 +277,9 @@ export const RoleSelector = (props: {
@@ -293,22 +299,39 @@ export const RoleSelector = (props: { leaveFrom="opacity-100" leaveTo="opacity-0" > - - {roleOptions.map((optionRole: RoleType) => ( - - {({ active, selected }) => ( -
- -
- )} -
- ))} + + {roleOptions.map((optionRole: RoleType) => { + const unsafeForPending = + memberKeyCeremonyPending && !isRoleCryptoSafe(optionRole.permissions) + return ( + + {({ active, selected }) => ( +
+ + {unsafeForPending && ( + + )} +
+ )} +
+ ) + })}
diff --git a/frontend/app/[team]/access/members/page.tsx b/frontend/app/[team]/access/members/page.tsx index 225890c5d..ed319e6a5 100644 --- a/frontend/app/[team]/access/members/page.tsx +++ b/frontend/app/[team]/access/members/page.tsx @@ -12,6 +12,7 @@ import { FaBan, FaChevronRight, FaSearch, FaTimesCircle, FaCopy } from 'react-ic import clsx from 'clsx' import Link from 'next/link' import { Avatar } from '@/components/common/Avatar' +import { ProfileCard } from '@/components/common/ProfileCard' import { RoleLabel } from '@/components/users/RoleLabel' import { getInviteLink } from '@/utils/crypto' import { userHasPermission } from '@/utils/access/permissions' @@ -57,7 +58,8 @@ export default function Members({ params }: { params: { team: string } }) { ? searchQuery !== '' ? membersData?.organisationMembers.filter( (member: OrganisationMemberType) => - member.fullName?.includes(searchQuery) || member.email?.includes(searchQuery) + member.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) || + member.email?.toLowerCase().includes(searchQuery.toLowerCase()) ) : membersData?.organisationMembers : [] @@ -142,16 +144,8 @@ export default function Members({ params }: { params: { team: string } }) { {filteredMembers.map((member: OrganisationMemberType) => ( - - -
-
- {member.fullName || member.email} {member.self && ' (You)'} -
- {member.fullName && ( -
{member.email}
- )} -
+ + diff --git a/frontend/app/[team]/access/scim/_components/SCIMEventRow.tsx b/frontend/app/[team]/access/scim/_components/SCIMEventRow.tsx new file mode 100644 index 000000000..c20d902a5 --- /dev/null +++ b/frontend/app/[team]/access/scim/_components/SCIMEventRow.tsx @@ -0,0 +1,138 @@ +'use client' + +import { Disclosure, Transition } from '@headlessui/react' +import { FaChevronRight } from 'react-icons/fa' +import clsx from 'clsx' +import { relativeTimeFromDates } from '@/utils/time' +import { EVENT_TYPE_LABELS, LogField, JsonBlock, ProviderLogo } from './shared' + +export function SCIMEventRow({ event }: { event: any }) { + const eventMeta = EVENT_TYPE_LABELS[event.eventType] || { + label: event.eventType, + color: 'text-neutral-500 bg-neutral-500/10 ring-neutral-500/20', + } + + const isError = event.status === 'ERROR' + + return ( + + {({ open }) => ( + <> + + + + + + {relativeTimeFromDates(new Date(event.timestamp))} + + + + {isError ? 'Error' : 'OK'} + + + + + {eventMeta.label} + + + + {event.scimToken ? ( +
+ + {event.scimToken.name} +
+ ) : ( + '-' + )} + + + {event.resourceName || '-'} + +
+ + + +
+ {new Date(event.timestamp).toISOString()} + {event.id} + {event.ipAddress || '-'} + {event.requestMethod} + {event.requestPath} + + = 400 ? 'text-red-500' : 'text-emerald-500' + )} + > + {event.responseStatus} + + +
+ +
+ {event.requestBody && ( +
+
Request Body
+ +
+ )} + {event.responseBody && ( +
+
+ Response Body +
+ +
+ )} +
+
+ +
+ + )} +
+ ) +} diff --git a/frontend/app/[team]/access/scim/_components/SCIMEventsTable.tsx b/frontend/app/[team]/access/scim/_components/SCIMEventsTable.tsx new file mode 100644 index 000000000..84a895044 --- /dev/null +++ b/frontend/app/[team]/access/scim/_components/SCIMEventsTable.tsx @@ -0,0 +1,53 @@ +'use client' + +import { Fragment } from 'react' +import { SCIMEventRow } from './SCIMEventRow' + +export function SCIMEventsTable({ + events, + pageSize, +}: { + events: any[] + pageSize?: number +}) { + return ( + + + + + + + + + + + + + {events.map((event: any, n: number) => ( + + {pageSize && n !== 0 && n % pageSize === 0 && ( + + + + )} + + + ))} + +
+ Time + + Status + + Event + + Provider + + Resource +
+
+ Page {n / pageSize + 1} +
+
+ ) +} diff --git a/frontend/app/[team]/access/scim/_components/SCIMTokenDialogs.tsx b/frontend/app/[team]/access/scim/_components/SCIMTokenDialogs.tsx new file mode 100644 index 000000000..afdbf9748 --- /dev/null +++ b/frontend/app/[team]/access/scim/_components/SCIMTokenDialogs.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useRef, useState } from 'react' +import { useMutation } from '@apollo/client' +import { toast } from 'react-toastify' +import clsx from 'clsx' +import { FaExclamationTriangle, FaPlus, FaTrash } from 'react-icons/fa' +import { Button } from '@/components/common/Button' +import CopyButton from '@/components/common/CopyButton' +import GenericDialog from '@/components/common/GenericDialog' +import { Input } from '@/components/common/Input' +import { GetSCIMTokens } from '@/graphql/queries/scim/getSCIMTokens.gql' +import { CreateSCIMTokenOp } from '@/graphql/mutations/scim/createSCIMToken.gql' +import { DeleteSCIMTokenOp } from '@/graphql/mutations/scim/deleteSCIMToken.gql' +import { EXPIRY_OPTIONS } from './shared' + +export function CreateSCIMTokenDialog({ organisationId }: { organisationId: string }) { + const [name, setName] = useState('') + const [expiryDays, setExpiryDays] = useState(90) + const [createdToken, setCreatedToken] = useState(null) + + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const [createToken, { loading }] = useMutation(CreateSCIMTokenOp, { + refetchQueries: [{ query: GetSCIMTokens, variables: { organisationId } }], + }) + + const handleCreate = async () => { + if (!name.trim()) return + + try { + const { data } = await createToken({ + variables: { + organisationId, + name: name.trim(), + expiryDays, + }, + }) + setCreatedToken(data.createScimToken.token) + toast.success('SCIM token created') + } catch (err: any) { + toast.error(err.message || 'Failed to create SCIM token') + } + } + + const handleClose = () => { + setName('') + setExpiryDays(90) + setCreatedToken(null) + } + + return ( + + Create token + + } + buttonVariant="primary" + ref={dialogRef} + onClose={handleClose} + > +
+ {createdToken ? ( + <> +
+ +

Copy this token now. It will not be shown again.

+
+
+ + {createdToken} + + +
+ + ) : ( + <> +
+ + +
+
+ +
+ {EXPIRY_OPTIONS.map((opt) => ( + + ))} +
+
+
+ +
+ + )} +
+
+ ) +} + +export function DeleteSCIMTokenDialog({ + tokenId, + tokenName, + organisationId, +}: { + tokenId: string + tokenName: string + organisationId: string +}) { + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const [deleteToken, { loading }] = useMutation(DeleteSCIMTokenOp, { + refetchQueries: [{ query: GetSCIMTokens, variables: { organisationId } }], + }) + + const handleDelete = async () => { + try { + await deleteToken({ variables: { tokenId } }) + toast.success('SCIM token deleted') + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message || 'Failed to delete SCIM token') + } + } + + return ( + } + buttonVariant="danger" + ref={dialogRef} + > +
+

+ Are you sure you want to delete the token{' '} + {tokenName}? Any + identity provider using this token will lose access. +

+
+ +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/scim/_components/SCIMTokensTable.tsx b/frontend/app/[team]/access/scim/_components/SCIMTokensTable.tsx new file mode 100644 index 000000000..c85b7cd2f --- /dev/null +++ b/frontend/app/[team]/access/scim/_components/SCIMTokensTable.tsx @@ -0,0 +1,128 @@ +'use client' + +import clsx from 'clsx' +import { relativeTimeFromDates } from '@/utils/time' +import { ProfileCard } from '@/components/common/ProfileCard' +import { ToggleSwitch } from '@/components/common/ToggleSwitch' +import { ProviderLogo } from './shared' +import { DeleteSCIMTokenDialog } from './SCIMTokenDialogs' + +export function SCIMTokensTable({ + tokens, + organisationId, + userCanManageSCIM, + onToggleToken, +}: { + tokens: any[] + organisationId: string + userCanManageSCIM: boolean + onToggleToken: (tokenId: string, currentActive: boolean) => void +}) { + return ( + + + + + + + + + + + + + {tokens.map((token: any) => ( + + + + + + + + + ))} + +
+ Provider + + Created + + Expires + + Last Used + + Active +
+
+ +
+
+ {token.name} + {!token.isActive && ( + + Disabled + + )} +
+ {token.id} +
+
+
+
+
+ {relativeTimeFromDates(new Date(token.createdAt))} + {token.createdBy && ' by'} +
+ {token.createdBy && ( + + )} +
+
+ {token.expiresAt ? ( + + {new Date(token.expiresAt) < new Date() + ? 'Expired' + : relativeTimeFromDates(new Date(token.expiresAt))} + + ) : ( + Never + )} + + + {token.lastUsedAt + ? relativeTimeFromDates(new Date(token.lastUsedAt)) + : 'Never'} + + + {userCanManageSCIM && ( + onToggleToken(token.id, token.isActive)} + size="sm" + /> + )} + + {userCanManageSCIM && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/app/[team]/access/scim/_components/shared.tsx b/frontend/app/[team]/access/scim/_components/shared.tsx new file mode 100644 index 000000000..21e0ff678 --- /dev/null +++ b/frontend/app/[team]/access/scim/_components/shared.tsx @@ -0,0 +1,131 @@ +'use client' + +import React, { useContext } from 'react' +import { FaKey } from 'react-icons/fa' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { vscDarkPlus, coldarkCold } from 'react-syntax-highlighter/dist/cjs/styles/prism' +import { ThemeContext } from '@/contexts/themeContext' +import CopyButton from '@/components/common/CopyButton' +import { EntraIDLogo, OktaLogo, JumpCloudLogo } from '@/components/common/logos' + +export const PROVIDER_PATTERNS: { + keywords: string[] + logo: React.FC<{ className?: string }> + label: string +}[] = [ + { keywords: ['entra', 'azure', 'microsoft'], logo: EntraIDLogo, label: 'Microsoft Entra ID' }, + { keywords: ['okta'], logo: OktaLogo, label: 'Okta' }, + { keywords: ['jumpcloud'], logo: JumpCloudLogo, label: 'JumpCloud' }, +] + +const logoSizeMap = { + sm: 'h-4 w-4', + md: 'h-5 w-5', +} + +export function getProviderIcon(name: string): React.FC<{ className?: string }> { + const lower = name.toLowerCase() + const match = PROVIDER_PATTERNS.find((p) => p.keywords.some((k) => lower.includes(k))) + return match ? match.logo : FaKey +} + +export function ProviderLogo({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) { + const lower = name.toLowerCase() + const match = PROVIDER_PATTERNS.find((p) => p.keywords.some((k) => lower.includes(k))) + const sizeClass = logoSizeMap[size] + if (match) { + const Logo = match.logo + return + } + return +} + +export const EXPIRY_OPTIONS = [ + { label: '30 days', value: 30 }, + { label: '90 days', value: 90 }, + { label: '1 year', value: 365 }, + { label: 'No expiration', value: null }, +] + +export const EVENT_TYPE_LABELS: Record = { + USER_CREATED: { + label: 'User Created', + color: 'text-emerald-500 bg-emerald-500/10 ring-emerald-500/20', + }, + USER_UPDATED: { label: 'User Updated', color: 'text-blue-500 bg-blue-500/10 ring-blue-500/20' }, + USER_DEACTIVATED: { + label: 'User Deactivated', + color: 'text-red-500 bg-red-500/10 ring-red-500/20', + }, + USER_REACTIVATED: { + label: 'User Reactivated', + color: 'text-emerald-500 bg-emerald-500/10 ring-emerald-500/20', + }, + GROUP_CREATED: { + label: 'Group Created', + color: 'text-emerald-500 bg-emerald-500/10 ring-emerald-500/20', + }, + GROUP_UPDATED: { + label: 'Group Updated', + color: 'text-blue-500 bg-blue-500/10 ring-blue-500/20', + }, + GROUP_DELETED: { + label: 'Group Deleted', + color: 'text-red-500 bg-red-500/10 ring-red-500/20', + }, + MEMBER_ADDED: { + label: 'Member Added', + color: 'text-emerald-500 bg-emerald-500/10 ring-emerald-500/20', + }, + MEMBER_REMOVED: { + label: 'Member Removed', + color: 'text-red-500 bg-red-500/10 ring-red-500/20', + }, +} + +export function LogField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label}: + {children} +
+ ) +} + +export function JsonBlock({ data }: { data: any }) { + const { theme } = useContext(ThemeContext) + + if (!data) return null + const parsed = typeof data === 'string' ? JSON.parse(data) : data + const formatted = JSON.stringify(parsed, null, 2) + + return ( +
+
+ +
+ + {formatted} + +
+ ) +} diff --git a/frontend/app/[team]/access/scim/connections/page.tsx b/frontend/app/[team]/access/scim/connections/page.tsx new file mode 100644 index 000000000..863ba8869 --- /dev/null +++ b/frontend/app/[team]/access/scim/connections/page.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useContext } from 'react' +import { useMutation, useQuery } from '@apollo/client' +import { toast } from 'react-toastify' +import { FaBan, FaKey } from 'react-icons/fa' +import { organisationContext } from '@/contexts/organisationContext' +import { GetSCIMTokens } from '@/graphql/queries/scim/getSCIMTokens.gql' +import { ToggleSCIMTokenOp } from '@/graphql/mutations/scim/toggleSCIMToken.gql' +import { userHasPermission } from '@/utils/access/permissions' +import Spinner from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' +import { CreateSCIMTokenDialog } from '../_components/SCIMTokenDialogs' +import { SCIMTokensTable } from '../_components/SCIMTokensTable' + +export default function SCIMConnectionsPage({ params }: { params: { team: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const userCanManageSCIM = organisation + ? userHasPermission(organisation.role!.permissions, 'SCIM', 'update') + : false + + const userCanReadSCIM = organisation + ? userHasPermission(organisation.role!.permissions, 'SCIM', 'read') + : false + + const { data, loading } = useQuery(GetSCIMTokens, { + variables: { organisationId: organisation?.id }, + skip: !organisation || !userCanReadSCIM, + }) + + const [toggleSCIMToken] = useMutation(ToggleSCIMTokenOp, { + refetchQueries: [{ query: GetSCIMTokens, variables: { organisationId: organisation?.id } }], + }) + + const tokens = data?.scimTokens || [] + + const handleToggleToken = async (tokenId: string, currentActive: boolean) => { + try { + await toggleSCIMToken({ + variables: { tokenId, isActive: !currentActive }, + }) + toast.success(currentActive ? 'Token disabled' : 'Token enabled') + } catch (err: any) { + toast.error(err.message || 'Failed to toggle token') + } + } + + if (!organisation) + return ( +
+ +
+ ) + + if (!userCanReadSCIM) + return ( +
+ + +
+ } + > + <> + + + ) + + return ( +
+
+
+
+

Provider Connections

+

+ Manage SCIM tokens for identity provider connections. +

+
+ {userCanManageSCIM && } +
+ + {loading && !data ? ( +
+ +
+ ) : tokens.length === 0 ? ( + + +
+ } + > + {userCanManageSCIM && } + + ) : ( + + )} +
+ + ) +} diff --git a/frontend/app/[team]/access/scim/layout.tsx b/frontend/app/[team]/access/scim/layout.tsx new file mode 100644 index 000000000..9d498b294 --- /dev/null +++ b/frontend/app/[team]/access/scim/layout.tsx @@ -0,0 +1,47 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import clsx from 'clsx' + +const tabs = [ + { name: 'Home', segment: '' }, + { name: 'Connections', segment: 'connections' }, + { name: 'Logs', segment: 'logs' }, +] + +export default function SCIMLayout({ children, params }: { children: React.ReactNode; params: { team: string } }) { + const pathname = usePathname() + // pathname: //access/scim or //access/scim/connections or //access/scim/logs + const segments = pathname?.split('/') || [] + // segments[4] is the sub-segment after "scim" + const activeSegment = segments[4] || '' + + return ( +
+ + {children} +
+ ) +} diff --git a/frontend/app/[team]/access/scim/logs/page.tsx b/frontend/app/[team]/access/scim/logs/page.tsx new file mode 100644 index 000000000..76cedfa97 --- /dev/null +++ b/frontend/app/[team]/access/scim/logs/page.tsx @@ -0,0 +1,327 @@ +'use client' + +import { Fragment, useContext, useState } from 'react' +import { NetworkStatus, useQuery } from '@apollo/client' +import { FaBan, FaCheckCircle, FaCircle, FaFilter } from 'react-icons/fa' +import { FiRefreshCw, FiChevronsDown } from 'react-icons/fi' +import { FaArrowRotateLeft } from 'react-icons/fa6' +import { Menu, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { organisationContext } from '@/contexts/organisationContext' +import { GetSCIMEvents } from '@/graphql/queries/scim/getSCIMEvents.gql' +import { GetSCIMTokens } from '@/graphql/queries/scim/getSCIMTokens.gql' +import { userHasPermission } from '@/utils/access/permissions' +import { dateToUnixTimestamp } from '@/utils/time' +import Spinner from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' +import { Button } from '@/components/common/Button' +import { SCIMEventsTable } from '../_components/SCIMEventsTable' +import { EVENT_TYPE_LABELS, getProviderIcon } from '../_components/shared' + +const PAGE_SIZE = 25 + +const STATUS_OPTIONS = [ + { code: 'SUCCESS', label: 'Success', color: 'emerald' }, + { code: 'ERROR', label: 'Error', color: 'red' }, +] as const + +const filterCategoryTitleStyle = + 'text-[11px] font-semibold text-neutral-500 tracking-widest uppercase' + +export default function SCIMLogsPage({ params }: { params: { team: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const [eventTypes, setEventTypes] = useState([]) + const [selectedTokenId, setSelectedTokenId] = useState(null) + const [selectedStatus, setSelectedStatus] = useState(null) + + const userCanReadSCIM = organisation + ? userHasPermission(organisation.role!.permissions, 'SCIM', 'read') + : false + + const { data: tokensData } = useQuery(GetSCIMTokens, { + variables: { organisationId: organisation?.id }, + skip: !organisation || !userCanReadSCIM, + }) + + const { data, loading, fetchMore, refetch, networkStatus } = useQuery(GetSCIMEvents, { + variables: { + organisationId: organisation?.id, + eventTypes: eventTypes.length > 0 ? eventTypes : undefined, + tokenId: selectedTokenId || undefined, + status: selectedStatus || undefined, + }, + skip: !organisation || !userCanReadSCIM, + fetchPolicy: 'network-only', + notifyOnNetworkStatusChange: true, + }) + + const isRefetching = networkStatus === NetworkStatus.refetch || loading + const isFetchingMore = networkStatus === NetworkStatus.fetchMore + + const tokens = tokensData?.scimTokens || [] + const events = data?.scimEvents?.events || [] + const totalCount = data?.scimEvents?.count || 0 + const endOfList = events.length >= totalCount + + const hasActiveFilters = + eventTypes.length > 0 || selectedTokenId !== null || selectedStatus !== null + + const clearFilters = () => { + setEventTypes([]) + setSelectedTokenId(null) + setSelectedStatus(null) + } + + const getLastEventTimestamp = () => + events.length > 0 ? dateToUnixTimestamp(events[events.length - 1].timestamp) : Date.now() + + const loadMore = () => { + if (loading || isFetchingMore) return + + const lastTs = getLastEventTimestamp() + + fetchMore({ + variables: { end: lastTs }, + updateQuery: (prev: any, { fetchMoreResult }: any) => { + if (!fetchMoreResult?.scimEvents?.events?.length) { + return prev + } + + return { + ...prev, + scimEvents: { + ...prev.scimEvents, + events: [...prev.scimEvents.events, ...fetchMoreResult.scimEvents.events], + count: prev.scimEvents.count, + }, + } + }, + }) + } + + const handleRefetch = async () => { + await refetch({ + organisationId: organisation?.id, + eventTypes: eventTypes.length > 0 ? eventTypes : undefined, + tokenId: selectedTokenId || undefined, + status: selectedStatus || undefined, + }) + } + + if (!organisation) + return ( +
+ +
+ ) + + if (!userCanReadSCIM) + return ( +
+ + +
+ } + > + <> + + + ) + + return ( +
+
+
+
+

Provisioning Logs

+

{totalCount} Events

+
+ +
+ {/* Filter popover */} + + {({ open }) => ( + <> +
+ +
+ + {hasActiveFilters && ( + + )} +
+
+
+ + + + {/* Status */} +
+
Status
+
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Event types */} +
+
Event Type
+
+ {Object.entries(EVENT_TYPE_LABELS).map(([key, meta]) => ( + + ))} +
+
+ + {/* Provider */} + {tokens.length > 0 && ( +
+
Provider
+
+ {tokens.map((token: any) => ( + + ))} +
+
+ )} + +
+ +
+
+
+ + )} +
+ + {/* Refresh */} + +
+
+ + {loading && events.length === 0 ? ( +
+ +
+ ) : events.length === 0 ? ( +
+ No provisioning events found. +
+ ) : ( + <> + + +
+ {!endOfList ? ( + + ) : ( + + {events.length ? 'No more' : 'No'} events to show + + )} +
+ + )} +
+
+ ) +} diff --git a/frontend/app/[team]/access/scim/page.tsx b/frontend/app/[team]/access/scim/page.tsx new file mode 100644 index 000000000..184b8d622 --- /dev/null +++ b/frontend/app/[team]/access/scim/page.tsx @@ -0,0 +1,283 @@ +'use client' + +import { useContext } from 'react' +import { useMutation, useQuery } from '@apollo/client' +import { toast } from 'react-toastify' +import Link from 'next/link' +import { FaBan, FaChevronRight, FaKey } from 'react-icons/fa' +import { ApiOrganisationPlanChoices } from '@/apollo/graphql' +import CopyButton from '@/components/common/CopyButton' +import { EmptyState } from '@/components/common/EmptyState' +import Spinner from '@/components/common/Spinner' +import { ToggleSwitch } from '@/components/common/ToggleSwitch' +import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' +import { PlanLabel } from '@/components/settings/organisation/PlanLabel' +import { organisationContext } from '@/contexts/organisationContext' +import { GetSCIMTokens } from '@/graphql/queries/scim/getSCIMTokens.gql' +import { GetSCIMEvents } from '@/graphql/queries/scim/getSCIMEvents.gql' +import GetOrganisations from '@/graphql/queries/getOrganisations.gql' +import { ToggleSCIMOp } from '@/graphql/mutations/scim/toggleSCIM.gql' +import { ToggleSCIMTokenOp } from '@/graphql/mutations/scim/toggleSCIMToken.gql' +import { userHasPermission } from '@/utils/access/permissions' +import { CreateSCIMTokenDialog } from './_components/SCIMTokenDialogs' +import { SCIMTokensTable } from './_components/SCIMTokensTable' +import { SCIMEventsTable } from './_components/SCIMEventsTable' + +export default function SCIMPage({ params }: { params: { team: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const userCanManageSCIM = organisation + ? userHasPermission(organisation.role!.permissions, 'SCIM', 'update') + : false + + const userCanReadSCIM = organisation + ? userHasPermission(organisation.role!.permissions, 'SCIM', 'read') + : false + + const { data, loading } = useQuery(GetSCIMTokens, { + variables: { organisationId: organisation?.id }, + skip: !organisation || !userCanReadSCIM, + }) + + const { data: eventsData, loading: eventsLoading } = useQuery(GetSCIMEvents, { + variables: { organisationId: organisation?.id }, + skip: !organisation || !userCanReadSCIM, + pollInterval: 10000, + }) + + const [toggleSCIM] = useMutation(ToggleSCIMOp, { + refetchQueries: [{ query: GetOrganisations }], + }) + + const [toggleSCIMToken] = useMutation(ToggleSCIMTokenOp, { + refetchQueries: [{ query: GetSCIMTokens, variables: { organisationId: organisation?.id } }], + }) + + const tokens = data?.scimTokens || [] + const previewTokens = tokens.slice(0, 3) + const allEvents = eventsData?.scimEvents?.events || [] + const previewEvents = allEvents.slice(0, 10) + const totalEvents = eventsData?.scimEvents?.count || 0 + const scimEnabled = organisation?.scimEnabled ?? false + + const handleToggleSCIM = async () => { + try { + await toggleSCIM({ + variables: { + organisationId: organisation!.id, + enabled: !scimEnabled, + }, + }) + toast.success(scimEnabled ? 'SCIM disabled' : 'SCIM enabled') + } catch (err: any) { + toast.error(err.message || 'Failed to toggle SCIM') + } + } + + const handleToggleToken = async (tokenId: string, currentActive: boolean) => { + try { + await toggleSCIMToken({ + variables: { tokenId, isActive: !currentActive }, + }) + toast.success(currentActive ? 'Token disabled' : 'Token enabled') + } catch (err: any) { + toast.error(err.message || 'Failed to toggle token') + } + } + + if (!organisation) + return ( +
+ +
+ ) + + if (!userCanReadSCIM) + return ( +
+ + +
+ } + > + <> + + + ) + + return ( +
+
+
+

SCIM Provisioning

+

+ Manage SCIM tokens for automatic user and group provisioning from your identity + provider. +

+
+ + {/* Master Toggle */} + {organisation.plan !== ApiOrganisationPlanChoices.En ? ( +
+
+
Enable SCIM
+
+ Upgrade to Enterprise to enable SCIM provisioning. +
+
+ + Enable SCIM + + } + buttonVariant="primary" + /> +
+ ) : ( +
+
+
Enable SCIM
+
+ {scimEnabled + ? 'SCIM is enabled. Identity providers can sync users and groups.' + : 'Enable SCIM to allow identity providers to sync users and groups.'} +
+
+ {userCanManageSCIM && ( + + )} +
+ )} + + {scimEnabled && ( + <> + {/* SCIM Base URL */} +
+
+
SCIM Base URL
+ + {typeof window !== 'undefined' + ? `${window.location.origin}/service/scim/v2/` + : '/service/scim/v2/'} + +
+ +
+ + {/* Provider Connections Preview */} +
+
+
+
Provider Connections
+
+ Identity provider connections via SCIM tokens. +
+
+
+ {userCanManageSCIM && ( + + )} +
+
+ + {loading && !data ? ( +
+ +
+ ) : tokens.length === 0 ? ( + + +
+ } + > + {userCanManageSCIM && ( + + )} + + ) : ( + <> + + {tokens.length > 3 && ( + + View all {tokens.length} connections + + + )} + + )} +
+ + {/* Provisioning Logs Preview */} +
+
+
+
Provisioning Logs
+
+ Recent SCIM provisioning activity from your identity providers. +
+
+ {allEvents.length > 0 && ( + + View all + + + )} +
+ + {eventsLoading && allEvents.length === 0 ? ( +
+ +
+ ) : allEvents.length === 0 ? ( +
+ No provisioning events yet. Events will appear here when your identity provider + syncs users or groups. +
+ ) : ( + <> + + {totalEvents > 10 && ( + + View all {totalEvents} events + + + )} + + )} +
+ + )} +
+ + ) +} diff --git a/frontend/app/[team]/access/service-accounts/[account]/_components/CreateServiceAccountTokenDialog.tsx b/frontend/app/[team]/access/service-accounts/[account]/_components/CreateServiceAccountTokenDialog.tsx index 05ad682e6..96972b929 100644 --- a/frontend/app/[team]/access/service-accounts/[account]/_components/CreateServiceAccountTokenDialog.tsx +++ b/frontend/app/[team]/access/service-accounts/[account]/_components/CreateServiceAccountTokenDialog.tsx @@ -11,6 +11,7 @@ import { forwardRef, Fragment, useContext, useImperativeHandle, useRef, useState import { FaCheckCircle, FaCircle, FaExternalLinkSquareAlt, FaPlus } from 'react-icons/fa' import { GetServiceAccountTokens } from '@/graphql/queries/service-accounts/getServiceAccountTokens.gql' import { CreateSAToken } from '@/graphql/mutations/service-accounts/createServiceAccountToken.gql' +import { CreateServerSideSAToken } from '@/graphql/mutations/service-accounts/createServerSideServiceAccountToken.gql' import { organisationContext } from '@/contexts/organisationContext' import { getUserKxPublicKey, @@ -35,17 +36,18 @@ const CreateServiceAccountTokenDialog = forwardRef( ( { serviceAccount, + effectivePermissions, }: { serviceAccount: ServiceAccountType + effectivePermissions?: string | null }, ref ) => { const { activeOrganisation: organisation } = useContext(organisationContext) const { keyring } = useContext(KeyringContext) - const userCanCreateTokens = organisation - ? userHasPermission(organisation.role?.permissions, 'ServiceAccountTokens', 'create') - : false + const perms = effectivePermissions ?? organisation?.role?.permissions + const userCanCreateTokens = userHasPermission(perms, 'ServiceAccountTokens', 'create') const serviceAccountHandler = serviceAccount.handlers?.find( (handler) => handler?.user.self === true @@ -61,6 +63,7 @@ const CreateServiceAccountTokenDialog = forwardRef( const [createPending, setCreatePending] = useState(false) const [createToken] = useMutation(CreateSAToken) + const [createServerSideToken] = useMutation(CreateServerSideSAToken) const reset = () => { setName('') @@ -78,6 +81,9 @@ const CreateServiceAccountTokenDialog = forwardRef( openModal, })) + const canUseClientSide = !!serviceAccountHandler && !!keyring + const canUseServerSide = serviceAccount.serverSideKeyManagementEnabled === true + const handleCreateNewSAToken = async (event: { preventDefault: () => void }) => { return new Promise(async (resolve, reject) => { event.preventDefault() @@ -85,58 +91,99 @@ const CreateServiceAccountTokenDialog = forwardRef( if (name.length === 0) { toast.error('You must enter a name for the token') reject() + return } - if (serviceAccountHandler && keyring) { - setCreatePending(true) - const wrappedKeyring = serviceAccountHandler.wrappedKeyring - - const userKxKeys = { - publicKey: await getUserKxPublicKey(keyring.publicKey), - privateKey: await getUserKxPrivateKey(keyring.privateKey), - } - - const serviceAccountKeyringString = await decryptAsymmetric( - wrappedKeyring, - userKxKeys.privateKey, - userKxKeys.publicKey - ) - - const serviceAccountKeys = JSON.parse(serviceAccountKeyringString) as OrganisationKeyring - - const saKxKeys = { - publicKey: await getUserKxPublicKey(serviceAccountKeys.publicKey), - privateKey: await getUserKxPrivateKey(serviceAccountKeys.privateKey), - } - - const { pssService, mutationPayload } = await generateSAToken( - serviceAccount.id, - saKxKeys, - name, - expiry.getExpiry() - ) - - await createToken({ - variables: mutationPayload, - refetchQueries: [ - { - query: GetServiceAccountTokens, - variables: { - orgId: organisation!.id, - id: serviceAccount.id, + setCreatePending(true) + + try { + if (canUseClientSide) { + // Client-side token generation: user is a handler and has the keyring + const wrappedKeyring = serviceAccountHandler!.wrappedKeyring + + const userKxKeys = { + publicKey: await getUserKxPublicKey(keyring!.publicKey), + privateKey: await getUserKxPrivateKey(keyring!.privateKey), + } + + const serviceAccountKeyringString = await decryptAsymmetric( + wrappedKeyring, + userKxKeys.privateKey, + userKxKeys.publicKey + ) + + const serviceAccountKeys = JSON.parse( + serviceAccountKeyringString + ) as OrganisationKeyring + + const saKxKeys = { + publicKey: await getUserKxPublicKey(serviceAccountKeys.publicKey), + privateKey: await getUserKxPrivateKey(serviceAccountKeys.privateKey), + } + + const { pssService, mutationPayload } = await generateSAToken( + serviceAccount.id, + saKxKeys, + name, + expiry.getExpiry() + ) + + await createToken({ + variables: mutationPayload, + refetchQueries: [ + { + query: GetServiceAccountTokens, + variables: { + orgId: organisation!.id, + id: serviceAccount.id, + }, }, + ], + }) + + setCliSAToken(pssService) + setApiSAToken(`ServiceAccount ${mutationPayload.token}`) + } else if (canUseServerSide) { + // Server-side token generation: SA has SSK enabled (e.g. team-owned SA) + const result = await createServerSideToken({ + variables: { + serviceAccountId: serviceAccount.id, + name, + expiry: expiry.getExpiry(), }, - ], - }) + refetchQueries: [ + { + query: GetServiceAccountTokens, + variables: { + orgId: organisation!.id, + id: serviceAccount.id, + }, + }, + ], + }) + + const tokenString = + result.data.createServerSideServiceAccountToken.tokenString + const tokenValue = tokenString.split(':')[2] + + setCliSAToken(tokenString) + setApiSAToken(`ServiceAccount ${tokenValue}`) + } else { + toast.error( + 'Cannot create token: you are not a handler for this service account and server-side key management is not enabled.' + ) + setCreatePending(false) + reject() + return + } - setCliSAToken(pssService) - setApiSAToken(`ServiceAccount ${mutationPayload.token}`) setCreatePending(false) toast.success('Created new service account token!') resolve(true) - } else { - console.log('keyring unavailable') - reject() + } catch (error) { + setCreatePending(false) + toast.error('Failed to create token') + reject(error) } }) } diff --git a/frontend/app/[team]/access/service-accounts/[account]/_components/DeleteServiceAccountTokenDialog.tsx b/frontend/app/[team]/access/service-accounts/[account]/_components/DeleteServiceAccountTokenDialog.tsx index 248668e9e..4af031d90 100644 --- a/frontend/app/[team]/access/service-accounts/[account]/_components/DeleteServiceAccountTokenDialog.tsx +++ b/frontend/app/[team]/access/service-accounts/[account]/_components/DeleteServiceAccountTokenDialog.tsx @@ -14,15 +14,16 @@ import { userHasPermission } from '@/utils/access/permissions' export const DeleteServiceAccountTokenDialog = ({ token, serviceAccountId, + effectivePermissions, }: { token: ServiceAccountTokenType serviceAccountId: string + effectivePermissions?: string | null }) => { const { activeOrganisation: organisation } = useContext(organisationContext) - const userCanDeleteTokens = organisation - ? userHasPermission(organisation.role?.permissions, 'ServiceAccountTokens', 'delete') - : false + const perms = effectivePermissions ?? organisation?.role?.permissions + const userCanDeleteTokens = userHasPermission(perms, 'ServiceAccountTokens', 'delete') const dialogRef = useRef<{ closeModal: () => void }>(null) diff --git a/frontend/app/[team]/access/service-accounts/[account]/_components/ServiceAccountTokens.tsx b/frontend/app/[team]/access/service-accounts/[account]/_components/ServiceAccountTokens.tsx index b08c01021..b5af4361d 100644 --- a/frontend/app/[team]/access/service-accounts/[account]/_components/ServiceAccountTokens.tsx +++ b/frontend/app/[team]/access/service-accounts/[account]/_components/ServiceAccountTokens.tsx @@ -15,12 +15,18 @@ import { GetServiceAccountTokens } from '@/graphql/queries/service-accounts/getS import { useQuery } from '@apollo/client' import Spinner from '@/components/common/Spinner' -export const ServiceAccountTokens = ({ account }: { account: ServiceAccountType }) => { +export const ServiceAccountTokens = ({ + account, + effectivePermissions, +}: { + account: ServiceAccountType + effectivePermissions: string | null +}) => { const { activeOrganisation: organisation } = useContext(organisationContext) - const userCanReadTokens = organisation - ? userHasPermission(organisation.role?.permissions, 'ServiceAccountTokens', 'read') - : false + // Use effective permissions (team role override) if provided, otherwise fall back to org role + const perms = effectivePermissions ?? organisation?.role?.permissions + const userCanReadTokens = userHasPermission(perms, 'ServiceAccountTokens', 'read') const tokenDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) @@ -41,7 +47,11 @@ export const ServiceAccountTokens = ({ account }: { account: ServiceAccountType
Manage access tokens for this Service Account
- + {tokens?.length! > 0 && (
@@ -142,7 +152,11 @@ export const ServiceAccountTokens = ({ account }: { account: ServiceAccountType {/* Delete Button*/}
- +
) diff --git a/frontend/app/[team]/access/service-accounts/[account]/page.tsx b/frontend/app/[team]/access/service-accounts/[account]/page.tsx index b04ebc980..e4d6d2eeb 100644 --- a/frontend/app/[team]/access/service-accounts/[account]/page.tsx +++ b/frontend/app/[team]/access/service-accounts/[account]/page.tsx @@ -4,18 +4,29 @@ import Spinner from '@/components/common/Spinner' import { organisationContext } from '@/contexts/organisationContext' import { GetServiceAccountDetail } from '@/graphql/queries/service-accounts/getServiceAccountDetail.gql' import { UpdateServiceAccountOp } from '@/graphql/mutations/service-accounts/updateServiceAccount.gql' -import { userHasPermission } from '@/utils/access/permissions' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { userHasPermission, userHasGlobalAccess } from '@/utils/access/permissions' import { useMutation, useQuery } from '@apollo/client' import Link from 'next/link' -import { useContext, useEffect, useState } from 'react' -import { FaBan, FaBoxOpen, FaChevronLeft, FaCog, FaEdit, FaNetworkWired } from 'react-icons/fa' +import { useContext, useEffect, useMemo, useState } from 'react' +import { + FaBan, + FaBoxOpen, + FaBuilding, + FaChevronLeft, + FaCog, + FaEdit, + FaNetworkWired, + FaUsersCog, +} from 'react-icons/fa' import { FaServer, FaArrowDownUpLock } from 'react-icons/fa6' import { DeleteServiceAccountDialog } from '../_components/DeleteServiceAccountDialog' import { AddAppButton } from './_components/AddAppsToServiceAccountsButton' -import { ServiceAccountType } from '@/apollo/graphql' +import { ServiceAccountType, TeamType } from '@/apollo/graphql' import { Avatar } from '@/components/common/Avatar' import { EmptyState } from '@/components/common/EmptyState' import { ServiceAccountRoleSelector } from '../_components/RoleSelector' +import { RoleLabel } from '@/components/users/RoleLabel' import { Button } from '@/components/common/Button' import { toast } from 'react-toastify' import CopyButton from '@/components/common/CopyButton' @@ -31,41 +42,80 @@ export default function ServiceAccount({ params }: { params: { team: string; acc const [name, setName] = useState('') - const userCanReadSA = organisation - ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'read') + // Org-level permissions (always use org role, not overridden by team) + const userCanReadTeams = organisation + ? userHasPermission(organisation.role!.permissions, 'Teams', 'read') : false - const userCanReadAppMemberships = organisation - ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'read', true) + const userIsGlobalAccess = organisation + ? userHasGlobalAccess(organisation.role!.permissions) : false const userCanViewNetworkAccess = organisation ? userHasPermission(organisation?.role?.permissions, 'NetworkAccessPolicies', 'read') : false - const userCanUpdateSA = organisation - ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'update') - : false - - const userCanDeleteSA = organisation - ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'delete') - : false - + // Always attempt the query — backend will return data if user has team-based access const { data, loading } = useQuery(GetServiceAccountDetail, { variables: { orgId: organisation?.id, id: params.account }, - skip: !organisation || !userCanReadSA, + skip: !organisation, fetchPolicy: 'cache-and-network', }) + const { data: teamsData } = useQuery(GetTeams, { + variables: { organisationId: organisation?.id }, + skip: !organisation || !userCanReadTeams, + }) + const [updateAccount] = useMutation(UpdateServiceAccountOp) - const account: ServiceAccountType = data?.serviceAccounts[0] + const account: ServiceAccountType | undefined = data?.serviceAccounts?.[0] + const isTeamOwned = !!account?.team + + // Team membership and ownership (from SA query's team data — no GetTeams dependency) + const isTeamOwner = isTeamOwned && account?.team?.owner?.id === organisation?.memberId + const isTeamMember = + isTeamOwned && + (account?.team?.members?.some((m) => m.orgMember?.id === organisation?.memberId) ?? false) + + // Effective permissions for SA-related checks. + // For team-owned SAs: team memberRole if set, else org role. + // For org-level SAs: org role. + // Team owner bypasses all permission checks via isTeamOwner. + const effectivePermissions = useMemo(() => { + if (!organisation?.role?.permissions) return null + if (!isTeamOwned || !account?.team) return organisation.role!.permissions + if (isTeamMember && account.team.memberRole?.permissions) { + return account.team.memberRole.permissions + } + return organisation.role!.permissions + }, [organisation, isTeamOwned, isTeamMember, account]) + + // Whether user has basic access to this team-owned SA + const hasTeamAccess = !isTeamOwned || isTeamOwner || isTeamMember || userIsGlobalAccess + + // SA-related permissions (team role override applies, team owner gets full access) + const effectiveCanUpdateSA = + isTeamOwner || userHasPermission(effectivePermissions, 'ServiceAccounts', 'update') + const effectiveCanDeleteSA = + isTeamOwner || userHasPermission(effectivePermissions, 'ServiceAccounts', 'delete') + const effectiveCanReadAppMemberships = + isTeamOwner || userHasPermission(effectivePermissions, 'ServiceAccounts', 'read', true) + + // Teams this SA belongs to (for the teams section list) + const accountTeams = useMemo(() => { + if (!teamsData?.teams || !account) return [] + return (teamsData.teams as TeamType[]).filter((team) => + team.members?.some((m) => m.serviceAccount?.id === account.id) + ) + }, [teamsData, account]) const nameUpdated = account ? account.name !== name : false const updateName = async () => { - if (!userCanUpdateSA) { - toast.error("You don't have the permissions requried to update Service Accounts") + if (!account) return + if (!effectiveCanUpdateSA) { + toast.error("You don't have the permissions required to update Service Accounts") } await updateAccount({ variables: { @@ -84,29 +134,12 @@ export default function ServiceAccount({ params }: { params: { team: string; acc toast.success('Updated account name!') } - const resetName = () => setName(account.name) + const resetName = () => account && setName(account.name) useEffect(() => { if (account) setName(account.name) }, [account]) - if (!userCanReadSA) - return ( -
- - - - } - > - <> - -
- ) - if (loading) return (
@@ -151,7 +184,7 @@ export default function ServiceAccount({ params }: { params: { team: string; acc className="custom bg-transparent hover:bg-neutral-500/10 rounded-lg transition ease w-full text-base font-medium" value={name} onChange={(e) => setName(e.target.value)} - readOnly={!userCanUpdateSA} + readOnly={!effectiveCanUpdateSA} maxLength={64} /> {nameUpdated ? ( @@ -169,13 +202,33 @@ export default function ServiceAccount({ params }: { params: { team: string; acc
)} - - {account.id} - +
+ + {account.id} + + {account.team ? ( + + + {account.team.name} + + ) : ( + + + Organisation + + )} +
@@ -188,8 +241,12 @@ export default function ServiceAccount({ params }: { params: { team: string; acc {account.serverSideKeyManagementEnabled ? 'Server-side' : 'Client-side'} KMS - {userCanUpdateSA && ( - + {effectiveCanUpdateSA && hasTeamAccess && !account.team?.id && ( + )}
@@ -203,8 +260,12 @@ export default function ServiceAccount({ params }: { params: { team: string; acc {/* Role Selector and Description */}
-
- +
+ + {isTeamOwned && (managed by team)}
@@ -215,15 +276,85 @@ export default function ServiceAccount({ params }: { params: { team: string; acc
+ {userCanReadTeams && ( +
+
+
Teams
+
Teams this account belongs to
+
+ +
+ {accountTeams.length > 0 ? ( + accountTeams.map((team) => ( +
+
+
+ + {team.name} + + {team.serviceAccountRole && ( + + )} +
+ {team.description && ( +
+ {team.description} +
+ )} +
+ {team.apps?.length || 0} app{team.apps?.length !== 1 ? 's' : ''} + {team.apps && team.apps.length > 0 && ( + <> ({team.apps.map((a) => a!.name).join(', ')}) + )} +
+
+ + + +
+ )) + ) : ( +
+ This account is not part of any teams. +
+ )} +
+
+ )} +
App Access
- Manage the Apps and Environments that this account has access to + {isTeamOwned ? ( + <> + App access for this account is managed through the{' '} + + {account.team!.name} + {' '} + team. + + ) : ( + 'Manage the Apps and Environments that this account has direct access to' + )}
- {userCanReadAppMemberships && account.appMemberships?.length! > 0 && ( + {!isTeamOwned && effectiveCanReadAppMemberships && account.appMemberships?.length! > 0 && ( - {userCanReadAppMemberships ? ( + {effectiveCanReadAppMemberships ? (
{account.appMemberships && account.appMemberships.length > 0 ? ( account.appMemberships.map((appMembership) => ( @@ -268,30 +399,36 @@ export default function ServiceAccount({ params }: { params: { team: string; acc
{/* Manage Button */} -
- - - -
+ {!isTeamOwned && ( +
+ + + +
+ )}
)) ) : (
} > - {userCanReadAppMemberships && ( + {!isTeamOwned && effectiveCanReadAppMemberships && (
- {account.networkPolicies?.length! > 0 && ( + {hasTeamAccess && account.networkPolicies?.length! > 0 && ( )}
- {account.networkPolicies?.length! > 0 ? ( + {!hasTeamAccess ? ( +
+ You must be a member of the{' '} + + {account.team!.name} + {' '} + team to manage network policies for this account. +
+ ) : account.networkPolicies?.length! > 0 ? (
{account.networkPolicies?.map((policy) => (
@@ -376,9 +524,28 @@ export default function ServiceAccount({ params }: { params: { team: string; acc
)} - + {hasTeamAccess ? ( + + ) : ( +
+
+
Tokens
+
Service account access tokens
+
+
+ You must be a member of the{' '} + + {account.team!.name} + {' '} + team to manage tokens for this account. +
+
+ )} - {userCanDeleteSA && ( + {effectiveCanDeleteSA && hasTeamAccess && (
Danger Zone
diff --git a/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx b/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx index c9217a8e4..b471953f7 100644 --- a/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx +++ b/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx @@ -1,8 +1,10 @@ import { ApiOrganisationPlanChoices, OrganisationMemberType, RoleType } from '@/apollo/graphql' import GenericDialog from '@/components/common/GenericDialog' +import { Alert } from '@/components/common/Alert' import { Fragment, useContext, useEffect, useRef, useState } from 'react' -import { FaChevronDown, FaPlus } from 'react-icons/fa' +import { FaChevronDown, FaPlus, FaUsersCog } from 'react-icons/fa' import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' import { GetServiceAccountHandlers } from '@/graphql/queries/service-accounts/getServiceAccountHandlers.gql' import { GetRoles } from '@/graphql/queries/organisation/getRoles.gql' import { GetServerKey } from '@/graphql/queries/syncing/getServerKey.gql' @@ -28,7 +30,16 @@ import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' const bip39 = require('bip39') -export const CreateServiceAccountDialog = () => { +export const CreateServiceAccountDialog = ({ + teamId, + teamName, + teamRole, +}: { + teamId?: string + teamName?: string + teamRole?: RoleType | null +} = {}) => { + const isTeamContext = !!teamId const { activeOrganisation: organisation } = useContext(organisationContext) const { data: roleData, loading: roleDataPending } = useQuery(GetRoles, { @@ -74,13 +85,15 @@ export const CreateServiceAccountDialog = () => { ) || [] useEffect(() => { - if (roleData?.roles) { + if (isTeamContext && teamRole) { + setRole(teamRole) + } else if (roleData?.roles) { const defaultRole = roleData?.roles.find( (role: RoleType) => role.name?.toLowerCase() === 'service' ) if (defaultRole) setRole(defaultRole) } - }, [roleData]) + }, [roleData, isTeamContext, teamRole]) const handleCreateServiceAccount = (e: { preventDefault: () => void }) => { return new Promise((resolve) => { @@ -92,9 +105,10 @@ export const CreateServiceAccountDialog = () => { const accountSeed = await organisationSeed(mnemonic, organisation!.id) const keyring = await organisationKeyring(accountSeed) - // Wrap keys for server if required + // Wrap keys for server if required. + // Team-owned SAs always enable SSK so all team members can generate tokens server-side. let serverKeys = undefined - if (thirdParty) { + if (thirdParty || isTeamContext) { const serverKey = serverKeyData.serverPublicKey const serverEncryptedKeyring = await encryptAsymmetric(JSON.stringify(keyring), serverKey) @@ -132,13 +146,23 @@ export const CreateServiceAccountDialog = () => { serverWrappedKeyring: serverKeys?.serverEncryptedKeyring || null, serverWrappedRecovery: serverKeys?.serverEncryptedMnemonic || null, handlers: handlerKeys, + teamId: teamId || null, }, refetchQueries: [ { query: GetServiceAccounts, variables: { orgId: organisation!.id }, }, + ...(teamId + ? [ + { + query: GetTeams, + variables: { organisationId: organisation!.id, teamId }, + }, + ] + : []), ], + awaitRefetchQueries: true, }) setCreatePending(false) @@ -153,12 +177,17 @@ export const CreateServiceAccountDialog = () => { }) } + const buttonLabel = isTeamContext ? 'Create Team Service Account' : 'Create Service Account' + const dialogTitle = isTeamContext + ? 'Create a Team Service Account' + : 'Create a new Service Account' + if (upsell) return ( - Create Service Account + {buttonLabel} } /> @@ -166,65 +195,82 @@ export const CreateServiceAccountDialog = () => { return ( - Create Service Account + {isTeamContext ? : } {buttonLabel} } buttonVariant="primary" - size="lg" + size="md" ref={dialogRef} > -
-
- -
- - - {({ open }) => ( - <> - -
- {role ? : <>Select a role} - -
-
- - {roleOptions.map((role: RoleType) => ( - - {({ active, selected }) => ( -
+
+ {isTeamContext && ( + + + This service account will be owned by {teamName} and only visible + to team members. + + + )} +
+ +
+ + {isTeamContext && teamRole ? ( +
+ + (set by team) +
+ ) : ( + + {({ open }) => ( + <> + +
+ {role ? : <>Select a role} + +
+
+ + {roleOptions.map((role: RoleType) => ( + + {({ active, selected }) => ( +
+ +
)} - > - -
- )} - - ))} - - + + ))} + + + )} + )} - +
diff --git a/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx b/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx index 6f761d41f..38ce9ee75 100644 --- a/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx +++ b/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx @@ -1,6 +1,7 @@ import { FaTrash, FaTrashAlt } from 'react-icons/fa' import { DeleteServiceAccountOp } from '@/graphql/mutations/service-accounts/deleteServiceAccount.gql' import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' import { useMutation } from '@apollo/client' import { toast } from 'react-toastify' import { useContext, useRef } from 'react' @@ -10,31 +11,50 @@ import { Button } from '@/components/common/Button' import GenericDialog from '@/components/common/GenericDialog' import { useRouter } from 'next/navigation' -export const DeleteServiceAccountDialog = ({ account }: { account: ServiceAccountType }) => { +export const DeleteServiceAccountDialog = ({ + account, + onDelete, +}: { + account: ServiceAccountType + onDelete?: () => void +}) => { const { activeOrganisation: organisation } = useContext(organisationContext) const dialogRef = useRef<{ closeModal: () => void }>(null) const [deleteAccount, { loading }] = useMutation(DeleteServiceAccountOp) + const router = useRouter() + const handleDelete = async () => { + const refetchQueries: any[] = [ + { query: GetServiceAccounts, variables: { orgId: organisation!.id } }, + ] + + if (account.team?.id) { + refetchQueries.push({ + query: GetTeams, + variables: { organisationId: organisation!.id, teamId: account.team.id }, + }) + } + const deleted = await deleteAccount({ variables: { id: account.id }, - refetchQueries: [{ query: GetServiceAccounts, variables: { orgId: organisation!.id } }], + refetchQueries, }) if (deleted.data.deleteServiceAccount.ok) { toast.success('Deleted service account!') if (dialogRef.current) { dialogRef.current.closeModal() } - handleRedirect() + if (onDelete) { + onDelete() + } else { + router.push(`/${organisation?.name}/access/service-accounts`) + } } } - const router = useRouter() - - const handleRedirect = () => router.push(`/${organisation?.name}/access/service-accounts`) - return ( + + Ownership + + Created @@ -122,7 +126,7 @@ export default function ServiceAccounts({ params }: { params: { team: string } }
{account.name}
-
{account.id}
+
{account.id}
@@ -130,6 +134,24 @@ export default function ServiceAccounts({ params }: { params: { team: string } } + + {account.team ? ( + + + {account.team.name} + + ) : ( + + + Organisation + + )} + + {relativeTimeFromDates(new Date(account.createdAt))} diff --git a/frontend/app/[team]/access/teams/[teamId]/_components/AddTeamAppsDialog.tsx b/frontend/app/[team]/access/teams/[teamId]/_components/AddTeamAppsDialog.tsx new file mode 100644 index 000000000..d27268fa2 --- /dev/null +++ b/frontend/app/[team]/access/teams/[teamId]/_components/AddTeamAppsDialog.tsx @@ -0,0 +1,390 @@ +'use client' + +import { AppType, TeamType, TeamAppEnvironmentType } from '@/apollo/graphql' +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { Checkbox } from '@/components/common/Checkbox' +import { organisationContext } from '@/contexts/organisationContext' +import { GetApps } from '@/graphql/queries/getApps.gql' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { AddTeamAppsOp } from '@/graphql/mutations/teams/addTeamApps.gql' +import { UpdateTeamAppEnvironmentsOp } from '@/graphql/mutations/teams/updateTeamAppEnvironments.gql' +import { RemoveTeamAppOp } from '@/graphql/mutations/teams/removeTeamApp.gql' +import { useMutation, useQuery } from '@apollo/client' +import { useContext, useRef, useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { FaChevronDown, FaExclamationTriangle, FaExternalLinkAlt, FaSearch, FaTimesCircle } from 'react-icons/fa' +import clsx from 'clsx' +import { toast } from 'react-toastify' +import { Disclosure } from '@headlessui/react' +import { FaCubes } from 'react-icons/fa6' + +export const AddTeamAppsDialog = ({ team }: { team: TeamType }) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + const params = useParams<{ team: string }>() + + const { data: appsData } = useQuery(GetApps, { + variables: { organisationId: organisation?.id }, + skip: !organisation, + }) + + const [addApps, { loading: addLoading }] = useMutation(AddTeamAppsOp) + const [updateEnvs, { loading: updateLoading }] = useMutation(UpdateTeamAppEnvironmentsOp) + const [removeApp, { loading: removeLoading }] = useMutation(RemoveTeamAppOp) + + const loading = addLoading || updateLoading || removeLoading + + const dialogRef = useRef<{ closeModal: () => void }>(null) + const [selectedEnvs, setSelectedEnvs] = useState>>({}) + const [searchQuery, setSearchQuery] = useState('') + const [isOpen, setIsOpen] = useState(false) + + // Build the initial state from existing team app-environment assignments + const existingAppIds = new Set(team.apps?.map((a) => a!.id) || []) + + const getInitialEnvs = (): Record> => { + const initial: Record> = {} + for (const tae of (team.appEnvironments || []) as TeamAppEnvironmentType[]) { + if (!tae?.app?.id || !tae?.environment?.id) continue + if (!initial[tae.app.id]) initial[tae.app.id] = new Set() + initial[tae.app.id].add(tae.environment.id) + } + return initial + } + + // Reset selected envs from team data when dialog opens + useEffect(() => { + if (isOpen) { + setSelectedEnvs(getInitialEnvs()) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + const allApps: AppType[] = appsData?.apps || [] + + const sseApps = allApps.filter((app) => app.sseEnabled) + const nonSseApps = allApps.filter((app) => !app.sseEnabled) + + const filteredSseApps = + searchQuery !== '' + ? sseApps.filter((app) => app.name?.toLowerCase().includes(searchQuery.toLowerCase())) + : sseApps + + const filteredNonSseApps = + searchQuery !== '' + ? nonSseApps.filter((app) => app.name?.toLowerCase().includes(searchQuery.toLowerCase())) + : nonSseApps + + const toggleEnv = (appId: string, envId: string) => { + setSelectedEnvs((prev) => { + const next = { ...prev } + if (!next[appId]) next[appId] = new Set() + else next[appId] = new Set(next[appId]) + + if (next[appId].has(envId)) next[appId].delete(envId) + else next[appId].add(envId) + + if (next[appId].size === 0) delete next[appId] + return next + }) + } + + const toggleAllEnvs = (appId: string, envIds: string[]) => { + setSelectedEnvs((prev) => { + const next = { ...prev } + const current = next[appId] || new Set() + const allSelected = envIds.every((id) => current.has(id)) + + if (allSelected) { + delete next[appId] + } else { + next[appId] = new Set(envIds) + } + return next + }) + } + + // Compute what changed vs the initial state + const computeChanges = () => { + const initial = getInitialEnvs() + const toAdd: { appId: string; envIds: string[] }[] = [] + const toUpdate: { appId: string; envIds: string[] }[] = [] + const toRemove: string[] = [] + + // Check apps in selectedEnvs + for (const [appId, envIds] of Object.entries(selectedEnvs)) { + if (!initial[appId]) { + // New app + toAdd.push({ appId, envIds: Array.from(envIds) }) + } else { + // Existing app — check if envs changed + const initialSet = initial[appId] + const changed = + envIds.size !== initialSet.size || + [...envIds].some((id) => !initialSet.has(id)) + if (changed) { + toUpdate.push({ appId, envIds: Array.from(envIds) }) + } + } + } + + // Check for removed apps (in initial but not in selectedEnvs) + for (const appId of Object.keys(initial)) { + if (!selectedEnvs[appId]) { + toRemove.push(appId) + } + } + + return { toAdd, toUpdate, toRemove } + } + + const { toAdd, toUpdate, toRemove } = computeChanges() + const hasChanges = toAdd.length > 0 || toUpdate.length > 0 || toRemove.length > 0 + + const handleSubmit = async () => { + if (!hasChanges) return + + try { + const refetchQueries = [ + { + query: GetTeams, + variables: { organisationId: organisation!.id, teamId: team.id }, + }, + ] + + // Add new apps + if (toAdd.length > 0) { + await addApps({ + variables: { + teamId: team.id, + appEnvs: toAdd, + }, + refetchQueries, + }) + } + + // Update existing apps with changed envs + for (const { appId, envIds } of toUpdate) { + await updateEnvs({ + variables: { teamId: team.id, appId, envIds }, + refetchQueries, + }) + } + + // Remove apps with all envs deselected + for (const appId of toRemove) { + await removeApp({ + variables: { teamId: team.id, appId }, + refetchQueries, + }) + } + + const parts = [] + if (toAdd.length > 0) parts.push(`added ${toAdd.length}`) + if (toUpdate.length > 0) parts.push(`updated ${toUpdate.length}`) + if (toRemove.length > 0) parts.push(`removed ${toRemove.length}`) + toast.success(`App access ${parts.join(', ')}`) + + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message) + } + } + + const reset = () => { + setSelectedEnvs({}) + setSearchQuery('') + setIsOpen(false) + } + + return ( + + Manage app access + + } + buttonVariant="primary" + ref={dialogRef} + size="lg" + onClose={reset} + onOpen={() => setIsOpen(true)} + > +
+

+ Manage this team's access to apps and environments. Only SSE-enabled apps can be + assigned to teams. +

+ +
+ + setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
+ + {filteredSseApps.length === 0 && filteredNonSseApps.length === 0 ? ( +

+ {searchQuery ? 'No apps match your search' : 'No SSE-enabled apps available.'} +

+ ) : ( +
+ {filteredSseApps.map((app: AppType) => { + const envIds = app.environments?.filter(Boolean).map((e) => e!.id) || [] + const appSelected = selectedEnvs[app.id] + const allEnvsSelected = appSelected && envIds.every((id) => appSelected.has(id)) + const isExisting = existingAppIds.has(app.id) + + return ( + + {({ open }) => ( +
+ +
+ + {app.name} + + {appSelected && ( + + {appSelected.size} env{appSelected.size !== 1 ? 's' : ''} + + )} + {isExisting && !appSelected && ( + + removing + + )} +
+ +
+ +
toggleAllEnvs(app.id, envIds)} + > + toggleAllEnvs(app.id, envIds)} + /> + All environments +
+
+ {app.environments?.map((env) => { + if (!env) return null + const isSelected = !!appSelected?.has(env.id) + return ( +
toggleEnv(app.id, env.id)} + > + + {env.name} + + toggleEnv(app.id, env.id)} + /> +
+ ) + })} +
+
+
+ )} +
+ ) + })} + + {filteredNonSseApps.length > 0 && ( + <> +
+ + {({ open }) => ( +
+ +
+ + + {filteredNonSseApps.length} hidden app + {filteredNonSseApps.length !== 1 ? 's' : ''} + +
+ +
+ +

+ These apps need SSE enabled in settings before they can be assigned to + teams. +

+ {filteredNonSseApps.map((app: AppType) => ( +
+ + {app.name} + + +
+ ))} +
+
+ )} +
+ + )} +
+ )} + +
+ + {Object.keys(selectedEnvs).length} app + {Object.keys(selectedEnvs).length !== 1 ? 's' : ''} with access + + +
+
+ + ) +} diff --git a/frontend/app/[team]/access/teams/[teamId]/_components/AddTeamMembersDialog.tsx b/frontend/app/[team]/access/teams/[teamId]/_components/AddTeamMembersDialog.tsx new file mode 100644 index 000000000..61e51333a --- /dev/null +++ b/frontend/app/[team]/access/teams/[teamId]/_components/AddTeamMembersDialog.tsx @@ -0,0 +1,352 @@ +'use client' + +import { OrganisationMemberType, ServiceAccountType, TeamMembershipType } from '@/apollo/graphql' +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { Checkbox } from '@/components/common/Checkbox' +import { ProfileCard } from '@/components/common/ProfileCard' +import { organisationContext } from '@/contexts/organisationContext' +import GetOrganisationMembers from '@/graphql/queries/organisation/getOrganisationMembers.gql' +import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { AddTeamMembersOp } from '@/graphql/mutations/teams/addTeamMembers.gql' +import { useApolloClient, useMutation, useQuery } from '@apollo/client' +import { useContext, useRef, useState } from 'react' +import { FaPlus, FaSearch, FaTimesCircle, FaUsers, FaRobot } from 'react-icons/fa' +import clsx from 'clsx' +import { toast } from 'react-toastify' +import { Tab } from '@headlessui/react' +import { Alert } from '@/components/common/Alert' + +export const AddTeamMembersDialog = ({ + teamId, + existingMembers, + mode = 'all', + buttonVariant = 'primary', +}: { + teamId: string + existingMembers: TeamMembershipType[] + mode?: 'all' | 'members' | 'service-accounts' + buttonVariant?: 'primary' | 'secondary' +}) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const { data: membersData } = useQuery(GetOrganisationMembers, { + variables: { organisationId: organisation?.id, role: null }, + skip: !organisation, + }) + + const { data: saData } = useQuery(GetServiceAccounts, { + variables: { orgId: organisation?.id }, + skip: !organisation, + }) + + const client = useApolloClient() + const [addMembers, { loading }] = useMutation(AddTeamMembersOp) + + const dialogRef = useRef<{ closeModal: () => void }>(null) + const [selectedMembers, setSelectedMembers] = useState>(new Set()) + const [selectedSAs, setSelectedSAs] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState('') + + const existingMemberIds = new Set( + existingMembers.filter((m) => m.orgMember).map((m) => m.orgMember!.id) + ) + + const existingSaIds = new Set( + existingMembers.filter((m) => m.serviceAccount).map((m) => m.serviceAccount!.id) + ) + + const availableMembers: OrganisationMemberType[] = + membersData?.organisationMembers?.filter( + (m: OrganisationMemberType) => !existingMemberIds.has(m.id) + ) || [] + + const availableSAs: ServiceAccountType[] = + saData?.serviceAccounts?.filter( + (sa: ServiceAccountType) => !existingSaIds.has(sa.id) && !sa.team + ) || [] + + const filteredMembers = + searchQuery !== '' + ? availableMembers.filter( + (m) => + m.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) || + m.email?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : availableMembers + + const filteredSAs = + searchQuery !== '' + ? availableSAs.filter((sa) => sa.name?.toLowerCase().includes(searchQuery.toLowerCase())) + : availableSAs + + const toggleMember = (id: string) => { + setSelectedMembers((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const toggleSA = (id: string) => { + setSelectedSAs((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const totalSelected = + (mode === 'service-accounts' ? 0 : selectedMembers.size) + + (mode === 'members' ? 0 : selectedSAs.size) + + const handleSubmit = async () => { + if (totalSelected === 0) return + try { + const promises: Promise[] = [] + + if (selectedMembers.size > 0) { + promises.push( + addMembers({ + variables: { + teamId, + memberIds: Array.from(selectedMembers), + memberType: 'USER', + }, + }) + ) + } + + if (selectedSAs.size > 0) { + promises.push( + addMembers({ + variables: { + teamId, + memberIds: Array.from(selectedSAs), + memberType: 'SERVICE', + }, + }) + ) + } + + await Promise.all(promises) + + await client.refetchQueries({ + include: [GetTeams], + }) + + const parts: string[] = [] + if (selectedMembers.size > 0) + parts.push(`${selectedMembers.size} member${selectedMembers.size !== 1 ? 's' : ''}`) + if (selectedSAs.size > 0) + parts.push(`${selectedSAs.size} service account${selectedSAs.size !== 1 ? 's' : ''}`) + toast.success(`Added ${parts.join(' and ')} to team`) + reset() + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message) + } + } + + const reset = () => { + setSelectedMembers(new Set()) + setSelectedSAs(new Set()) + setSearchQuery('') + } + + const selectedSummary = () => { + const parts: string[] = [] + if (mode !== 'service-accounts' && selectedMembers.size > 0) + parts.push(`${selectedMembers.size} member${selectedMembers.size !== 1 ? 's' : ''}`) + if (mode !== 'members' && selectedSAs.size > 0) + parts.push(`${selectedSAs.size} service account${selectedSAs.size !== 1 ? 's' : ''}`) + return parts.length > 0 ? parts.join(', ') : '0 selected' + } + + const dialogTitle = + mode === 'members' + ? 'Add members to team' + : mode === 'service-accounts' + ? 'Add service accounts to team' + : 'Add members to team' + + const buttonLabel = + mode === 'members' + ? 'Add Members' + : mode === 'service-accounts' + ? 'Add Service Accounts' + : 'Add Members' + + const searchBar = ( +
+ + setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
+ ) + + const membersList = ( +
+ {filteredMembers.length === 0 ? ( +

+ {availableMembers.length === 0 + ? 'All organisation members are already in this team' + : 'No members match your search'} +

+ ) : ( + filteredMembers.map((member: OrganisationMemberType) => ( +
toggleMember(member.id)} + > + + toggleMember(member.id)} + /> +
+ )) + )} +
+ ) + + const saList = ( +
+ {filteredSAs.length === 0 ? ( +

+ {availableSAs.length === 0 + ? 'All service accounts are already in this team' + : 'No service accounts match your search'} +

+ ) : ( + filteredSAs.map((sa: ServiceAccountType) => ( +
toggleSA(sa.id)} + > + + toggleSA(sa.id)} + /> +
+ )) + )} +
+ ) + + return ( + + {buttonLabel} + + } + buttonVariant={buttonVariant} + ref={dialogRef} + onClose={reset} + > +
+ {mode === 'all' ? ( + { + setSearchQuery('') + }} + > + + + clsx( + 'p-2 text-xs font-medium border-b -mb-px focus:outline-none transition ease flex items-center gap-1.5', + selected + ? 'border-emerald-500 font-semibold text-zinc-900 dark:text-zinc-100' + : 'border-transparent text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100' + ) + } + > + Members + {selectedMembers.size > 0 && ( + + {selectedMembers.size} + + )} + + + clsx( + 'p-2 text-xs font-medium border-b -mb-px focus:outline-none transition ease flex items-center gap-1.5', + selected + ? 'border-emerald-500 font-semibold text-zinc-900 dark:text-zinc-100' + : 'border-transparent text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100' + ) + } + > + Service Accounts + {selectedSAs.size > 0 && ( + + {selectedSAs.size} + + )} + + + + {searchBar} + + + {membersList} + {saList} + + + ) : ( + <> + {mode === 'service-accounts' && ( + + These accounts can be managed by other teams or users outside this team. + + )} + {searchBar} + {mode === 'members' ? membersList : saList} + + )} + +
+ {selectedSummary()} + +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/teams/[teamId]/_components/RemoveTeamAppDialog.tsx b/frontend/app/[team]/access/teams/[teamId]/_components/RemoveTeamAppDialog.tsx new file mode 100644 index 000000000..50f75cd7d --- /dev/null +++ b/frontend/app/[team]/access/teams/[teamId]/_components/RemoveTeamAppDialog.tsx @@ -0,0 +1,72 @@ +'use client' + +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { organisationContext } from '@/contexts/organisationContext' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { RemoveTeamAppOp } from '@/graphql/mutations/teams/removeTeamApp.gql' +import { useMutation } from '@apollo/client' +import { useContext, useRef } from 'react' +import { FaTrashAlt } from 'react-icons/fa' +import { toast } from 'react-toastify' + +export const RemoveTeamAppDialog = ({ + teamId, + teamName, + appId, + appName, +}: { + teamId: string + teamName: string + appId: string + appName: string +}) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + const [removeApp, { loading }] = useMutation(RemoveTeamAppOp) + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const handleRemove = async () => { + try { + await removeApp({ + variables: { teamId, appId }, + refetchQueries: [ + { + query: GetTeams, + variables: { organisationId: organisation!.id }, + }, + ], + }) + toast.success(`Removed ${appName} from ${teamName}`) + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message) + } + } + + return ( + + +
+ } + buttonVariant="danger" + ref={dialogRef} + > +
+

+ This will remove {appName}{' '} + from team {teamName}. Team + members will lose access to environments granted through this team unless they have + individual access, or access through any other teams. +

+
+ +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/teams/[teamId]/_components/RemoveTeamMemberDialog.tsx b/frontend/app/[team]/access/teams/[teamId]/_components/RemoveTeamMemberDialog.tsx new file mode 100644 index 000000000..6a77e5526 --- /dev/null +++ b/frontend/app/[team]/access/teams/[teamId]/_components/RemoveTeamMemberDialog.tsx @@ -0,0 +1,73 @@ +'use client' + +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { organisationContext } from '@/contexts/organisationContext' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { RemoveTeamMemberOp } from '@/graphql/mutations/teams/removeTeamMember.gql' +import { useMutation } from '@apollo/client' +import { useContext, useRef } from 'react' +import { FaTimes } from 'react-icons/fa' +import { toast } from 'react-toastify' + +export const RemoveTeamMemberDialog = ({ + teamId, + memberId, + memberName, + memberType = 'USER', +}: { + teamId: string + memberId: string + memberName: string + memberType?: string +}) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const [removeMember, { loading }] = useMutation(RemoveTeamMemberOp) + + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const handleRemove = async () => { + try { + await removeMember({ + variables: { + teamId, + memberId, + memberType, + }, + refetchQueries: [ + { + query: GetTeams, + variables: { organisationId: organisation!.id, teamId }, + }, + ], + }) + toast.success(`Removed ${memberName} from team`) + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message) + } + } + + return ( + Remove from team} + buttonVariant="danger" + ref={dialogRef} + size="sm" + > +
+

+ Remove {memberName} from this team? Their team-based environment key + grants will be revoked. +

+
+ +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/teams/[teamId]/_components/TransferTeamOwnershipDialog.tsx b/frontend/app/[team]/access/teams/[teamId]/_components/TransferTeamOwnershipDialog.tsx new file mode 100644 index 000000000..3e8a2acaa --- /dev/null +++ b/frontend/app/[team]/access/teams/[teamId]/_components/TransferTeamOwnershipDialog.tsx @@ -0,0 +1,126 @@ +'use client' + +import { TeamMembershipType, TeamType } from '@/apollo/graphql' +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { ProfileCard } from '@/components/common/ProfileCard' +import { RoleLabel } from '@/components/users/RoleLabel' +import { organisationContext } from '@/contexts/organisationContext' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { TransferTeamOwnershipOp } from '@/graphql/mutations/teams/transferTeamOwnership.gql' +import { useMutation } from '@apollo/client' +import { useContext, useRef, useState } from 'react' +import { FaCrown } from 'react-icons/fa' +import clsx from 'clsx' +import { toast } from 'react-toastify' + +export const TransferTeamOwnershipDialog = ({ team }: { team: TeamType }) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + const [transferOwnership, { loading }] = useMutation(TransferTeamOwnershipOp) + const dialogRef = useRef<{ closeModal: () => void }>(null) + const [selectedMemberId, setSelectedMemberId] = useState(null) + + const humanMembers = (team.members || []).filter( + (m): m is TeamMembershipType & { orgMember: NonNullable } => + !!m.orgMember + ) + + const handleTransfer = async () => { + if (!selectedMemberId) return + try { + await transferOwnership({ + variables: { teamId: team.id, newOwnerId: selectedMemberId }, + refetchQueries: [ + { + query: GetTeams, + variables: { organisationId: organisation!.id, teamId: team.id }, + }, + ], + }) + const newOwner = humanMembers.find((m) => m.orgMember.id === selectedMemberId) + toast.success( + `Transferred ownership to ${newOwner?.orgMember.fullName || newOwner?.orgMember.email || 'member'}` + ) + setSelectedMemberId(null) + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message) + } + } + + return ( + + {team.owner ? 'Transfer ownership' : 'Set owner'} + + } + buttonVariant="secondary" + ref={dialogRef} + onClose={() => setSelectedMemberId(null)} + > +
+

+ {team.owner + ? 'Select a team member to transfer ownership to. The new owner will have full control over this team.' + : 'This team has no owner. Select a team member to assign as owner.'} +

+ +
+ {humanMembers.map((membership) => { + const isCurrentOwner = team.owner?.id === membership.orgMember.id + const isSelected = selectedMemberId === membership.orgMember.id + + return ( +
{ + if (!isCurrentOwner) setSelectedMemberId(membership.orgMember.id) + }} + > +
+ +
+ {membership.orgMember.role && ( + + )} +
+
+ {isCurrentOwner && ( + + Current owner + + )} +
+
+
+ ) + })} +
+ +
+ +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/teams/[teamId]/_components/UpdateTeamDialog.tsx b/frontend/app/[team]/access/teams/[teamId]/_components/UpdateTeamDialog.tsx new file mode 100644 index 000000000..462c81f32 --- /dev/null +++ b/frontend/app/[team]/access/teams/[teamId]/_components/UpdateTeamDialog.tsx @@ -0,0 +1,219 @@ +'use client' + +import { RoleType, TeamType } from '@/apollo/graphql' +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { Input } from '@/components/common/Input' +import { RoleLabel } from '@/components/users/RoleLabel' +import { organisationContext } from '@/contexts/organisationContext' +import { GetTeams } from '@/graphql/queries/teams/getTeams.gql' +import { GetRoles } from '@/graphql/queries/organisation/getRoles.gql' +import { UpdateTeamOp } from '@/graphql/mutations/teams/updateTeam.gql' +import { useMutation, useQuery } from '@apollo/client' +import { Fragment, useContext, useEffect, useRef, useState } from 'react' +import { FaCog, FaChevronDown, FaUserShield, FaRobot } from 'react-icons/fa' +import { Listbox } from '@headlessui/react' +import clsx from 'clsx' +import { toast } from 'react-toastify' + +const RoleSelector = ({ + value, + onChange, + options, + icon, + title, + subtitle, +}: { + value: RoleType | null + onChange: (v: RoleType | null) => void + options: RoleType[] + icon: React.ReactNode + title: string + subtitle: string +}) => ( +
+
+
{icon}
+
+ +

{subtitle}

+
+ + {({ open }) => ( + <> + +
+ {value ? ( + + ) : ( + None (use org role) + )} + +
+
+ + + {({ active }) => ( +
+ None (use org role) +
+ )} +
+ {options.map((role: RoleType) => ( + + {({ active }) => ( +
+ +
+ )} +
+ ))} +
+ + )} +
+
+
+
+
+) + +export const UpdateTeamDialog = ({ team }: { team: TeamType }) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const { data: roleData } = useQuery(GetRoles, { + variables: { orgId: organisation?.id }, + skip: !organisation, + }) + + const [updateTeam, { loading }] = useMutation(UpdateTeamOp) + + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const [name, setName] = useState(team.name) + const [description, setDescription] = useState(team.description || '') + const [memberRole, setMemberRole] = useState( + (team.memberRole as RoleType) || null + ) + const [saRole, setSaRole] = useState( + (team.serviceAccountRole as RoleType) || null + ) + + // Sync state when team prop updates (e.g. after refetch) + useEffect(() => { + setName(team.name) + setDescription(team.description || '') + setMemberRole((team.memberRole as RoleType) || null) + setSaRole((team.serviceAccountRole as RoleType) || null) + }, [team.name, team.description, team.memberRole, team.serviceAccountRole]) + + const roleOptions: RoleType[] = + roleData?.roles?.filter((role: RoleType) => role.name?.toLowerCase() !== 'owner') || [] + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await updateTeam({ + variables: { + teamId: team.id, + ...(team.isScimManaged ? {} : { name, description: description || null }), + memberRoleId: memberRole?.id || '', + serviceAccountRoleId: saRole?.id || '', + }, + refetchQueries: [ + { + query: GetTeams, + variables: { organisationId: organisation!.id, teamId: team.id }, + }, + ], + }) + toast.success('Team updated') + dialogRef.current?.closeModal() + } catch (err: any) { + toast.error(err.message) + } + } + + return ( + + Settings + + } + buttonVariant="secondary" + ref={dialogRef} + onClose={() => { + setName(team.name) + setDescription(team.description || '') + setMemberRole((team.memberRole as RoleType) || null) + setSaRole((team.serviceAccountRole as RoleType) || null) + }} + > +
+
+ +
+ +
+ +