44
55import graphene
66from graphene import relay
7+ from graphene .types .generic import GenericScalar
78from graphene_django import DjangoObjectType
89from graphql_relay import to_global_id , from_global_id
910from 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
8497class 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+
226266class 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+
385487class 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):
628769class 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 )
659859class 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