diff --git a/descope/__init__.py b/descope/__init__.py index c26f8815e..d11f1ddbb 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -37,6 +37,8 @@ ) from descope.management.sso_settings import ( AttributeMapping, + FGAGroupMapping, + FGAGroupMappingRelation, OIDCAttributeMapping, RoleMapping, SSOOIDCSettings, @@ -85,6 +87,8 @@ "SAMLIDPGroupsMappingInfo", "SAMLIDPRoleGroupMappingInfo", "AttributeMapping", + "FGAGroupMapping", + "FGAGroupMappingRelation", "OIDCAttributeMapping", "RoleMapping", "SSOOIDCSettings", diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 2f55888a3..de0e3c31e 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Dict, List, Optional from descope._http_base import HTTPBase from descope.management.common import MgmtV1 @@ -12,6 +12,22 @@ def __init__(self, groups: List[str], role_name: str): self.role_name = role_name +class FGAGroupMappingRelation: + """A single FGA relation that maps an IDP group to an FGA resource/relation.""" + + def __init__(self, resource: str, relation_definition: str, namespace: str): + self.resource = resource + self.relation_definition = relation_definition + self.namespace = namespace + + +class FGAGroupMapping: + """A list of FGA relations to apply for an IDP group.""" + + def __init__(self, relations: Optional[List[FGAGroupMappingRelation]] = None): + self.relations = relations + + class AttributeMapping: """Map Descope user attributes to IDP user attributes""" @@ -93,6 +109,7 @@ def __init__( grant_type: Optional[str] = None, issuer: Optional[str] = None, groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) + fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations ): self.name = name self.client_id = client_id @@ -110,6 +127,7 @@ def __init__( self.grant_type = grant_type self.issuer = issuer self.groups_priority = groups_priority + self.fga_mappings = fga_mappings class SSOSAMLSettings: @@ -127,6 +145,9 @@ def __init__( default_sso_roles: Optional[List[str]] = None, idp_additional_certs: Optional[List[str]] = None, groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) + fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations + config_fga_tenant_id_resource_prefix: Optional[str] = None, + config_fga_tenant_id_resource_suffix: Optional[str] = None, # NOTICE - the following fields should be overridden only in case of SSO migration, otherwise, do not modify these fields sp_acs_url: Optional[str] = None, sp_entity_id: Optional[str] = None, @@ -141,6 +162,9 @@ def __init__( self.sp_acs_url = sp_acs_url self.sp_entity_id = sp_entity_id self.groups_priority = groups_priority + self.fga_mappings = fga_mappings + self.config_fga_tenant_id_resource_prefix = config_fga_tenant_id_resource_prefix + self.config_fga_tenant_id_resource_suffix = config_fga_tenant_id_resource_suffix class SSOSAMLSettingsByMetadata: @@ -155,6 +179,9 @@ def __init__( role_mappings: Optional[List[RoleMapping]] = None, default_sso_roles: Optional[List[str]] = None, groups_priority: Optional[List[str]] = None, # list of group names in priority order (first = highest priority) + fga_mappings: Optional[Dict[str, FGAGroupMapping]] = None, # map of IDP group name -> FGA relations + config_fga_tenant_id_resource_prefix: Optional[str] = None, + config_fga_tenant_id_resource_suffix: Optional[str] = None, # NOTICE - the following fields should be overridden only in case of SSO migration, otherwise, do not modify these fields sp_acs_url: Optional[str] = None, sp_entity_id: Optional[str] = None, @@ -166,6 +193,9 @@ def __init__( self.sp_acs_url = sp_acs_url self.sp_entity_id = sp_entity_id self.groups_priority = groups_priority + self.fga_mappings = fga_mappings + self.config_fga_tenant_id_resource_prefix = config_fga_tenant_id_resource_prefix + self.config_fga_tenant_id_resource_suffix = config_fga_tenant_id_resource_suffix class SSOSettings(HTTPBase): @@ -497,6 +527,27 @@ def _attribute_mapping_to_dict( "customAttributes": attribute_mapping.custom_attributes, } + @staticmethod + def _fga_mappings_to_dict( + fga_mappings: Optional[Dict[str, FGAGroupMapping]], + ) -> Optional[dict]: + if fga_mappings is None: + return None + result: dict = {} + for group_name, mapping in fga_mappings.items(): + relations = [] + if mapping is not None and mapping.relations: + for relation in mapping.relations: + relations.append( + { + "resource": relation.resource, + "relationDefinition": relation.relation_definition, + "namespace": relation.namespace, + } + ) + result[group_name] = {"relations": relations} + return result + @staticmethod def _compose_configure_oidc_settings_body( tenant_id: str, @@ -538,6 +589,7 @@ def _compose_configure_oidc_settings_body( "grantType": settings.grant_type, "issuer": settings.issuer, "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettings._fga_mappings_to_dict(settings.fga_mappings), }, "domains": domains, } @@ -566,6 +618,9 @@ def _compose_configure_saml_settings_body( "roleMappings": SSOSettings._role_mapping_to_dict(settings.role_mappings), "defaultSSORoles": settings.default_sso_roles, "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettings._fga_mappings_to_dict(settings.fga_mappings), + "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, + "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, }, "redirectUrl": redirect_url, "domains": domains, @@ -592,6 +647,9 @@ def _compose_configure_saml_settings_by_metadata_body( "roleMappings": SSOSettings._role_mapping_to_dict(settings.role_mappings), "defaultSSORoles": settings.default_sso_roles, "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettings._fga_mappings_to_dict(settings.fga_mappings), + "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, + "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, }, "redirectUrl": redirect_url, "domains": domains, diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 0b0cfb6ef..198f4fa33 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -6,6 +6,8 @@ from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.management.common import MgmtV1 from descope.management.sso_settings import ( + FGAGroupMapping, + FGAGroupMappingRelation, OIDCAttributeMapping, SSOOIDCSettings, SSOSAMLSettings, @@ -216,6 +218,7 @@ def test_configure_oidc_settings(self): "picture": "picture", }, "groupsPriority": ["group1"], + "fgaMappings": None, }, "domains": ["domain.com"], }, @@ -312,6 +315,9 @@ def test_configure_saml_settings(self): "spEntityId": "spentityid", "defaultSSORoles": ["aa", "bb"], "groupsPriority": ["group1"], + "fgaMappings": None, + "configFGATenantIDResourcePrefix": None, + "configFGATenantIDResourceSuffix": None, }, "redirectUrl": "https://redirect.com", "domains": ["domain.com"], @@ -397,6 +403,9 @@ def test_configure_saml_settings_by_metadata(self): "spEntityId": "spentityid", "defaultSSORoles": ["aa", "bb"], "groupsPriority": ["group1"], + "fgaMappings": None, + "configFGATenantIDResourcePrefix": None, + "configFGATenantIDResourceSuffix": None, }, "redirectUrl": "https://redirect.com", "domains": ["domain.com"], @@ -468,6 +477,9 @@ def test_configure_saml_settings_with_additional_certs(self): "spEntityId": None, "defaultSSORoles": ["aa", "bb"], "groupsPriority": ["group1"], + "fgaMappings": None, + "configFGATenantIDResourcePrefix": None, + "configFGATenantIDResourceSuffix": None, }, "redirectUrl": "https://redirect.com", "domains": ["domain.com"], @@ -480,6 +492,277 @@ def test_configure_saml_settings_with_additional_certs(self): def test_attribute_mapping_to_dict(self): self.assertRaises(ValueError, SSOSettings._attribute_mapping_to_dict, None) + def test_fga_mappings_to_dict(self): + # None input returns None + self.assertIsNone(SSOSettings._fga_mappings_to_dict(None)) + + # Empty dict returns empty dict + self.assertEqual(SSOSettings._fga_mappings_to_dict({}), {}) + + # Group with relations is serialized into camelCase keys + mappings = { + "admins": FGAGroupMapping( + relations=[ + FGAGroupMappingRelation( + resource="tenant:t1", + relation_definition="member", + namespace="tenant", + ), + FGAGroupMappingRelation( + resource="tenant:t1", + relation_definition="owner", + namespace="tenant", + ), + ], + ), + "viewers": FGAGroupMapping(), + } + self.assertEqual( + SSOSettings._fga_mappings_to_dict(mappings), + { + "admins": { + "relations": [ + { + "resource": "tenant:t1", + "relationDefinition": "member", + "namespace": "tenant", + }, + { + "resource": "tenant:t1", + "relationDefinition": "owner", + "namespace": "tenant", + }, + ], + }, + "viewers": {"relations": []}, + }, + ) + + def test_configure_saml_settings_with_fga_mappings(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.sso.configure_saml_settings( + "tenant-id", + SSOSAMLSettings( + idp_url="http://dummy.com", + idp_entity_id="ent1234", + idp_cert="cert", + fga_mappings={ + "admins": FGAGroupMapping( + relations=[ + FGAGroupMappingRelation( + resource="tenant:t1", + relation_definition="member", + namespace="tenant", + ), + ], + ), + }, + config_fga_tenant_id_resource_prefix="tenant:", + config_fga_tenant_id_resource_suffix="", + ), + "https://redirect.com", + ["domain.com"], + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "tenantId": "tenant-id", + "settings": { + "idpUrl": "http://dummy.com", + "entityId": "ent1234", + "idpCert": "cert", + "idpAdditionalCerts": None, + "attributeMapping": None, + "roleMappings": [], + "spACSUrl": None, + "spEntityId": None, + "defaultSSORoles": None, + "groupsPriority": None, + "fgaMappings": { + "admins": { + "relations": [ + { + "resource": "tenant:t1", + "relationDefinition": "member", + "namespace": "tenant", + }, + ], + }, + }, + "configFGATenantIDResourcePrefix": "tenant:", + "configFGATenantIDResourceSuffix": "", + }, + "redirectUrl": "https://redirect.com", + "domains": ["domain.com"], + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_configure_saml_settings_by_metadata_with_fga_mappings(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.sso.configure_saml_settings_by_metadata( + "tenant-id", + SSOSAMLSettingsByMetadata( + idp_metadata_url="http://dummy.com/metadata", + fga_mappings={ + "admins": FGAGroupMapping( + relations=[ + FGAGroupMappingRelation( + resource="tenant:t1", + relation_definition="member", + namespace="tenant", + ), + ], + ), + }, + config_fga_tenant_id_resource_prefix="tenant:", + config_fga_tenant_id_resource_suffix="-suffix", + ), + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "tenantId": "tenant-id", + "settings": { + "idpMetadataUrl": "http://dummy.com/metadata", + "attributeMapping": None, + "roleMappings": [], + "spACSUrl": None, + "spEntityId": None, + "defaultSSORoles": None, + "groupsPriority": None, + "fgaMappings": { + "admins": { + "relations": [ + { + "resource": "tenant:t1", + "relationDefinition": "member", + "namespace": "tenant", + }, + ], + }, + }, + "configFGATenantIDResourcePrefix": "tenant:", + "configFGATenantIDResourceSuffix": "-suffix", + }, + "redirectUrl": None, + "domains": None, + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_configure_oidc_settings_with_fga_mappings(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.sso.configure_oidc_settings( + "tenant-id", + SSOOIDCSettings( + name="myName", + client_id="cid", + fga_mappings={ + "admins": FGAGroupMapping( + relations=[ + FGAGroupMappingRelation( + resource="tenant:t1", + relation_definition="member", + namespace="tenant", + ), + ], + ), + }, + ), + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "tenantId": "tenant-id", + "settings": { + "name": "myName", + "clientId": "cid", + "clientSecret": None, + "redirectUrl": None, + "authUrl": None, + "tokenUrl": None, + "userDataUrl": None, + "scope": None, + "JWKsUrl": None, + "userAttrMapping": None, + "manageProviderTokens": False, + "callbackDomain": None, + "prompt": None, + "grantType": None, + "issuer": None, + "groupsPriority": None, + "fgaMappings": { + "admins": { + "relations": [ + { + "resource": "tenant:t1", + "relationDefinition": "member", + "namespace": "tenant", + }, + ], + }, + }, + }, + "domains": None, + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + # Testing DEPRECATED functions def test_get_settings(self): client = DescopeClient(