Skip to content

Commit 7435386

Browse files
committed
feat: Refactor SSO API
1 parent 5126797 commit 7435386

29 files changed

Lines changed: 1805 additions & 645 deletions

File tree

packages/backend/apps/sso/schema.py

Lines changed: 292 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import graphene
66
from graphene import relay
7+
from graphene.types.generic import GenericScalar
78
from graphene_django import DjangoObjectType
89
from graphql_relay import to_global_id, from_global_id
910
from django.shortcuts import get_object_or_404
@@ -33,6 +34,9 @@ class SSOConnectionType(DjangoObjectType):
3334
is_saml = graphene.Boolean()
3435
is_oidc = graphene.Boolean()
3536
sp_metadata_url = graphene.String()
37+
sp_acs_url = graphene.String()
38+
sp_entity_id = graphene.String()
39+
oidc_callback_url = graphene.String()
3640

3741
class Meta:
3842
model = models.TenantSSOConnection
@@ -80,6 +84,15 @@ def resolve_sp_metadata_url(self, info):
8084
return f"{api_url}/api/sso/saml/{self.id}/metadata"
8185
return None
8286

87+
def resolve_sp_acs_url(self, info):
88+
return self.sp_acs_url if self.is_saml else None
89+
90+
def resolve_sp_entity_id(self, info):
91+
return self.sp_entity_id if self.is_saml else None
92+
93+
def resolve_oidc_callback_url(self, info):
94+
return self.oidc_callback_url if self.is_oidc else None
95+
8396

8497
class SSOConnectionConnection(graphene.Connection):
8598
class Meta:
@@ -223,6 +236,33 @@ class Meta:
223236
node = PasskeyType
224237

225238

239+
class TenantPasskeyType(graphene.ObjectType):
240+
id = graphene.ID()
241+
name = graphene.String()
242+
authenticator_type = graphene.String()
243+
transports = GenericScalar()
244+
is_active = graphene.Boolean()
245+
last_used_at = graphene.DateTime()
246+
use_count = graphene.Int()
247+
device_type = graphene.String()
248+
created_at = graphene.DateTime()
249+
user_email = graphene.String()
250+
user_name = graphene.String()
251+
252+
def resolve_id(self, info):
253+
return to_global_id("PasskeyType", self.id)
254+
255+
def resolve_user_email(self, info):
256+
return self.user.email if hasattr(self, "user") and self.user else None
257+
258+
def resolve_user_name(self, info):
259+
if not hasattr(self, "user") or not self.user:
260+
return None
261+
first = getattr(self.user.profile, "first_name", "") or ""
262+
last = getattr(self.user.profile, "last_name", "") or ""
263+
return f"{first} {last}".strip() or self.user.email
264+
265+
226266
class SSOAuditLogType(DjangoObjectType):
227267
"""GraphQL type for SSO audit logs."""
228268

@@ -259,6 +299,21 @@ class Meta:
259299
node = SSOAuditLogType
260300

261301

302+
class SSODiscoveryConnectionType(graphene.ObjectType):
303+
id = graphene.String()
304+
name = graphene.String()
305+
type = graphene.String()
306+
tenant_id = graphene.String()
307+
tenant_name = graphene.String()
308+
login_url = graphene.String()
309+
310+
311+
class SSODiscoveryResultType(graphene.ObjectType):
312+
sso_available = graphene.Boolean()
313+
require_sso = graphene.Boolean()
314+
connections = graphene.List(SSODiscoveryConnectionType)
315+
316+
262317
# ==================
263318
# Mutations
264319
# ==================
@@ -382,6 +437,53 @@ def mutate(cls, root, info, id):
382437
return cls(sso_connection=connection)
383438

384439

440+
class TestSSOConnectionCheckType(graphene.ObjectType):
441+
name = graphene.String()
442+
status = graphene.String()
443+
message = graphene.String()
444+
details = GenericScalar()
445+
446+
447+
class TestSSOConnectionPayload(graphene.ObjectType):
448+
connectionId = graphene.String()
449+
connectionName = graphene.String()
450+
connectionType = graphene.String()
451+
overallStatus = graphene.String()
452+
checks = graphene.List(TestSSOConnectionCheckType)
453+
testedAt = graphene.String()
454+
455+
456+
class TestSSOConnectionMutation(graphene.Mutation):
457+
"""Test an SSO connection configuration."""
458+
459+
class Arguments:
460+
id = graphene.ID(required=True)
461+
462+
result = graphene.Field(TestSSOConnectionPayload)
463+
464+
@classmethod
465+
def mutate(cls, root, info, id):
466+
from .services.connection_test import test_sso_connection
467+
468+
_, pk = from_global_id(id)
469+
connection = get_object_or_404(
470+
models.TenantSSOConnection,
471+
pk=pk,
472+
tenant=info.context.tenant,
473+
)
474+
result = test_sso_connection(connection)
475+
return cls(
476+
result={
477+
"connectionId": result["connectionId"],
478+
"connectionName": result["connectionName"],
479+
"connectionType": result["connectionType"],
480+
"overallStatus": result["overallStatus"],
481+
"checks": result["checks"],
482+
"testedAt": result["testedAt"],
483+
}
484+
)
485+
486+
385487
class CreateSCIMTokenMutation(mutations.SerializerMutation):
386488
"""Create a new SCIM token."""
387489

@@ -619,6 +721,45 @@ def mutate_and_get_payload(cls, root, info, id, **kwargs):
619721
return cls(deleted_ids=[id])
620722

621723

724+
class DeleteTenantPasskeyMutation(graphene.Mutation):
725+
"""Delete a passkey as tenant admin (for any tenant member)."""
726+
727+
class Arguments:
728+
id = graphene.ID(required=True)
729+
730+
ok = graphene.Boolean()
731+
732+
@classmethod
733+
def mutate(cls, root, info, id):
734+
from apps.multitenancy.models import TenantMembership
735+
736+
_, pk = from_global_id(id)
737+
passkey = get_object_or_404(models.UserPasskey, pk=pk, is_active=True)
738+
tenant = info.context.tenant
739+
740+
if not TenantMembership.objects.filter(tenant=tenant, user=passkey.user).exists():
741+
raise ValueError("Passkey does not belong to a tenant member")
742+
743+
passkey.is_active = False
744+
passkey.save(update_fields=["is_active"])
745+
746+
from .services import get_client_ip
747+
748+
request = info.context._request if hasattr(info.context, "_request") else info.context
749+
ip_address = get_client_ip(request) if hasattr(request, "META") else None
750+
751+
models.SSOAuditLog.log_event(
752+
event_type=constants.SSOAuditEventType.PASSKEY_REMOVED,
753+
tenant=tenant,
754+
user=passkey.user,
755+
description=f'Passkey "{passkey.name}" removed by admin {info.context.user.email}',
756+
ip_address=ip_address,
757+
metadata={"removed_by": info.context.user.email, "passkey_owner": passkey.user.email},
758+
)
759+
760+
return cls(ok=True)
761+
762+
622763
# ==================
623764
# Queries
624765
# ==================
@@ -628,10 +769,10 @@ def mutate_and_get_payload(cls, root, info, id, **kwargs):
628769
class Query(graphene.ObjectType):
629770
"""SSO queries available to authenticated users."""
630771

631-
# User's own data
632772
my_passkeys = graphene.relay.ConnectionField(PasskeyConnection)
633773
my_sessions = graphene.relay.ConnectionField(SSOSessionConnection)
634774
my_devices = graphene.relay.ConnectionField(UserDeviceConnection)
775+
sso_discover = graphene.Field(SSODiscoveryResultType, email=graphene.String(required=True))
635776

636777
@staticmethod
637778
def resolve_my_passkeys(root, info, **kwargs):
@@ -654,6 +795,65 @@ def resolve_my_devices(root, info, **kwargs):
654795
return []
655796
return models.UserDevice.objects.filter(user=user)
656797

798+
@staticmethod
799+
def resolve_sso_discover(root, info, email):
800+
from django.db import models as db_models
801+
802+
email = (email or "").strip().lower()
803+
if not email or "@" not in email:
804+
return {"sso_available": False, "require_sso": False, "connections": []}
805+
806+
domain = email.split("@")[-1]
807+
connections = (
808+
models.TenantSSOConnection.objects.filter(
809+
status=constants.SSOConnectionStatus.ACTIVE,
810+
)
811+
.filter(
812+
db_models.Q(allowed_domains__contains=[domain])
813+
| db_models.Q(allowed_domains=[])
814+
| db_models.Q(allowed_domains__isnull=True)
815+
)
816+
.select_related("tenant")
817+
)
818+
819+
matching_connections = []
820+
for conn in connections:
821+
tenant_domains = getattr(conn.tenant, "domains", None)
822+
domain_matches = tenant_domains and domain in tenant_domains
823+
allowed_matches = conn.allowed_domains and domain in conn.allowed_domains
824+
if domain_matches or allowed_matches:
825+
matching_connections.append(conn)
826+
827+
seen_ids = set()
828+
unique_connections = []
829+
for conn in matching_connections:
830+
if conn.id not in seen_ids:
831+
seen_ids.add(conn.id)
832+
unique_connections.append(conn)
833+
834+
if not unique_connections:
835+
return {"sso_available": False, "require_sso": False, "connections": []}
836+
837+
require_sso = any(getattr(conn, "enforce_sso", False) for conn in unique_connections)
838+
from django.conf import settings
839+
840+
api_url = getattr(settings, "API_URL", "http://localhost:5001")
841+
return {
842+
"sso_available": True,
843+
"require_sso": require_sso,
844+
"connections": [
845+
{
846+
"id": str(conn.id),
847+
"name": conn.name,
848+
"type": conn.connection_type,
849+
"tenant_id": str(conn.tenant.id),
850+
"tenant_name": conn.tenant.name,
851+
"login_url": f"{api_url}/api/sso/{conn.connection_type}/{conn.id}/login",
852+
}
853+
for conn in unique_connections
854+
],
855+
}
856+
657857

658858
@permission_classes(policies.IsTenantMemberAccess)
659859
class TenantSSOQuery(graphene.ObjectType):
@@ -668,7 +868,19 @@ class TenantSSOQuery(graphene.ObjectType):
668868
sso_connections = graphene.relay.ConnectionField(SSOConnectionConnection)
669869
sso_connection = graphene.Field(SSOConnectionType, id=graphene.ID())
670870
scim_tokens = graphene.relay.ConnectionField(SCIMTokenConnection)
671-
sso_audit_logs = graphene.relay.ConnectionField(SSOAuditLogConnection)
871+
sso_audit_logs = graphene.relay.ConnectionField(
872+
SSOAuditLogConnection,
873+
event_type=graphene.String(),
874+
user_email=graphene.String(),
875+
success=graphene.Boolean(),
876+
start_date=graphene.String(),
877+
end_date=graphene.String(),
878+
search=graphene.String(),
879+
)
880+
tenant_passkeys = graphene.List(
881+
TenantPasskeyType,
882+
search=graphene.String(),
883+
)
672884

673885
@staticmethod
674886
@permission_classes(requires("security.view"))
@@ -691,8 +903,78 @@ def resolve_scim_tokens(root, info, **kwargs):
691903

692904
@staticmethod
693905
@permission_classes(requires("security.view"))
694-
def resolve_sso_audit_logs(root, info, **kwargs):
695-
return models.SSOAuditLog.objects.filter(tenant=info.context.tenant)[:100]
906+
def resolve_sso_audit_logs(
907+
root,
908+
info,
909+
event_type=None,
910+
user_email=None,
911+
success=None,
912+
start_date=None,
913+
end_date=None,
914+
search=None,
915+
**kwargs,
916+
):
917+
from datetime import datetime, timedelta
918+
from django.utils import timezone
919+
from django.db import models as db_models
920+
921+
logs = models.SSOAuditLog.objects.filter(tenant=info.context.tenant).select_related("user", "sso_connection")
922+
923+
if start_date:
924+
try:
925+
start_dt = datetime.fromisoformat(start_date)
926+
logs = logs.filter(created_at__gte=start_dt)
927+
except ValueError:
928+
pass
929+
930+
if end_date:
931+
try:
932+
end_dt = datetime.fromisoformat(end_date) + timedelta(days=1)
933+
logs = logs.filter(created_at__lt=end_dt)
934+
except ValueError:
935+
pass
936+
937+
if not start_date and not end_date:
938+
default_cutoff = timezone.now() - timedelta(days=90)
939+
logs = logs.filter(created_at__gte=default_cutoff)
940+
941+
if event_type:
942+
logs = logs.filter(event_type=event_type)
943+
if user_email:
944+
logs = logs.filter(user__email__icontains=user_email)
945+
if success is not None:
946+
logs = logs.filter(success=success)
947+
if search:
948+
logs = logs.filter(
949+
db_models.Q(event_description__icontains=search)
950+
| db_models.Q(user__email__icontains=search)
951+
| db_models.Q(ip_address__icontains=search)
952+
)
953+
954+
return logs.order_by("-created_at")
955+
956+
@staticmethod
957+
@permission_classes(requires("security.passkeys.manage"))
958+
def resolve_tenant_passkeys(root, info, search=None, **kwargs):
959+
from apps.multitenancy.models import TenantMembership
960+
from django.db.models import Q
961+
962+
tenant = info.context.tenant
963+
tenant_members = TenantMembership.objects.filter(tenant=tenant).values_list("user_id", flat=True)
964+
passkeys = (
965+
models.UserPasskey.objects.filter(user_id__in=tenant_members, is_active=True)
966+
.select_related("user", "user__profile")
967+
.order_by("-created_at")
968+
)
969+
if search:
970+
search_lower = search.strip().lower()
971+
passkeys = passkeys.filter(
972+
Q(user__email__icontains=search_lower)
973+
| Q(user__profile__first_name__icontains=search_lower)
974+
| Q(user__profile__last_name__icontains=search_lower)
975+
| Q(name__icontains=search_lower)
976+
)
977+
return list(passkeys)
696978

697979

698980
# ==================
@@ -733,7 +1015,13 @@ class TenantOwnerMutation(graphene.ObjectType):
7331015
deactivate_sso_connection = permission_classes(requires("security.sso.manage"))(
7341016
DeactivateSSOConnectionMutation.Field()
7351017
)
1018+
test_sso_connection = permission_classes(requires("security.sso.manage"))(TestSSOConnectionMutation.Field())
7361019

7371020
# SCIM Token management - requires security.sso.manage
7381021
create_scim_token = permission_classes(requires("security.sso.manage"))(CreateSCIMTokenMutation.Field())
7391022
revoke_scim_token = permission_classes(requires("security.sso.manage"))(RevokeSCIMTokenMutation.Field())
1023+
1024+
# Passkey management (tenant admin) - requires security.passkeys.manage
1025+
delete_tenant_passkey = permission_classes(requires("security.passkeys.manage"))(
1026+
DeleteTenantPasskeyMutation.Field()
1027+
)

0 commit comments

Comments
 (0)