from copy import deepcopy from cryptojwt.jwk.jwk import key_from_jwk_dict from django.conf import settings from django.contrib.auth import get_user_model from django.db import models # from django.db.models.signals import post_save from django.utils.translation import gettext as _ from spid_cie_oidc.entity.abstract_models import TimeStampedModel from spid_cie_oidc.entity.models import ( ENTITY_TYPES, ENTITY_STATUS, FederationEntityConfiguration ) from spid_cie_oidc.entity.jwtse import create_jws from spid_cie_oidc.entity.jwk_x5c import update_jwks_with_x5c from spid_cie_oidc.entity.validators import validate_public_jwks from spid_cie_oidc.entity.utils import iat_now, exp_from_now from spid_cie_oidc.entity.settings import FEDERATION_DEFAULT_EXP from spid_cie_oidc.entity.models import get_first_self_trust_anchor from . settings import FEDERATION_DEFAULT_POLICY from .validators import validate_entity_configuration import json import logging import uuid logger = logging.getLogger(__name__) class FederationEntityProfile(TimeStampedModel): """ Federation OnBoarding profile. It optionally defines trust marks templates """ name = models.CharField(max_length=33, help_text=_("Profile name. ")) profile_category = models.CharField( max_length=64, help_text=_("Profile id. It SHOULD be a URL but feel free to put whatever"), choices=[(i, i) for i in ENTITY_TYPES], ) profile_id = models.CharField( max_length=1024, help_text=_("Profile id. It SHOULD be a URL but feel free to put whatever"), unique=True, ) trust_mark_template = models.JSONField( help_text=_("trust marks template for this profile"), default=dict ) class Meta: verbose_name = "Federation Entity Profile" verbose_name_plural = "Federation Entity Profiles" @property def trust_mark_template_as_json(self): return json.dumps(self.trust_mark_template) def __str__(self): return f"{self.name} {self.profile_id}" class FederationDescendant(TimeStampedModel): """ Federation OnBoarding entries. """ def def_uid(): return f"autouid-{uuid.uuid4()}" uid = models.CharField( max_length=1024, default=def_uid, unique=True, help_text=_( "an unique code that identifies this entry. " "For italian public service it may be the IPA code." ), ) name = models.CharField( max_length=33, help_text=_( "human readable name of this entity. " "It may be a unit or organization name" ), ) sub = models.URLField( max_length=255, blank=False, null=False, unique=True, help_text=_("URL that identifies this Entity in the Federation."), validators=[validate_entity_configuration], ) type = models.CharField( max_length=33, blank=True, default="openid_relying_party", choices=[(i, i) for i in ENTITY_TYPES], help_text=_("OpenID Connect Federation entity type"), ) registrant = models.ManyToManyField( get_user_model(), help_text=_( "Logged in users can modify only sub, contacts and jwks " "attributes, if they're owner of this entry. " ), blank=True, ) jwks = models.JSONField( blank=False, null=False, help_text=_("a list of public keys"), default=list, validators = [validate_public_jwks] ) metadata_policy = models.JSONField( blank=True, help_text=_( "if present it overloads the DEFAULT policy. This will be applied " "recursively to all the descendants." ), default=dict ) metadata = models.JSONField( blank=True, help_text=_( "if present it overloads the descendants metadata, " "in the defined parts. This will be applied to the subordinate only." ), default=dict ) constraints = models.JSONField( blank=True, help_text=_("if present overloads the DEFAULT policy"), default=dict ) extended_claims = models.JSONField( blank=True, help_text=_( "a dictionary containing any other claim like: " "jti, crti, policy_language_crit and any other extension" ), default=dict, ) status = models.CharField( max_length=33, default=False, help_text=_("Its entity configuration is periodically fetched and validated."), choices=[(i, i) for i in ENTITY_STATUS.keys()], ) status_log = models.JSONField(blank=True, help_text=_("status log"), default=dict) is_active = models.BooleanField( default=False, help_text=_("If this entity is active. ") ) class Meta: verbose_name = "Federation Entity Descendant" verbose_name_plural = "Federation Entity Descendants" @property def trust_marks(self): # profiles = FederationEntityAssignedProfile.objects.filter(descendant=self) return [i.trust_mark for i in profiles] @property def trust_marks_as_json(self): return json.dumps(self.trust_marks) @property def entity_profiles(self): return [ i.profile.profile_category for i in FederationEntityAssignedProfile.objects.filter(descendant=self) ] def entity_statement_as_dict(self, iss: str = None, aud: list = None) -> dict: ta = get_first_self_trust_anchor(iss) private_key = key_from_jwk_dict(ta.jwks_fed[0]) if hasattr(settings, "X509_COMMON_NAME"): self.jwks = update_jwks_with_x5c( jwks = self.jwks, private_key=private_key.private_key(), subject = self.sub, path_length = None, is_ca_or_int = True ) policies = { k: FEDERATION_DEFAULT_POLICY.get(k, {}) for k in self.entity_profiles } # apply custom policies if defined policies.update(self.metadata_policy) data = { "exp": exp_from_now(minutes=FEDERATION_DEFAULT_EXP), "iat": iat_now(), "iss": ta.sub, "sub": self.sub, "jwks": {"keys": self.jwks} } if policies: data["metadata_policy"] = policies # OpenID Federation 8.1.2: source_endpoint enables optimized refresh if ta.fetch_endpoint: data["source_endpoint"] = ta.fetch_endpoint if aud: data["aud"] = [aud] if isinstance(aud, str) else aud # add contacts contacts = FederationDescendantContact.objects.filter(entity=self).values_list( "contact", flat=True ) if contacts: for k, v in data.get("metadata_policy", {}).items(): if data["metadata_policy"][k].get("contacts"): data["metadata_policy"][k]["contacts"].update( {"add": [i for i in contacts]} ) else: data["metadata_policy"][k]["contacts"] = { "add": [i for i in contacts] } # include active trust marks tm = self.trust_marks if tm: data["trust_marks"] = tm return data def entity_statement_as_json(self, iss: str = None, aud: list = None) -> str: return json.dumps(self.entity_statement_as_dict(iss, aud)) def entity_statement_preview(self): return self.entity_statement_as_json() def entity_statement_as_jws(self, iss: str = None, aud: list = None) -> str: issuer = get_first_self_trust_anchor(iss) return create_jws( self.entity_statement_as_dict(iss, aud), issuer.jwks_fed[0], alg=issuer.default_signature_alg, typ="entity-statement+jwt" ) def __str__(self): return "{} [{} and {}]".format( self.sub, self.status, "active" if self.is_active else "--" ) class FederationEntityAssignedProfile(TimeStampedModel): descendant = models.ForeignKey(FederationDescendant, on_delete=models.CASCADE) profile = models.ForeignKey(FederationEntityProfile, on_delete=models.CASCADE) issuer = models.ForeignKey(FederationEntityConfiguration, on_delete=models.CASCADE) class Meta: verbose_name = "Federation Entity Descendant Assigned Profile" verbose_name_plural = "Federation Descendant Assigned Profiles" @property def trust_mark_as_dict(self) -> dict: data = deepcopy(self.profile.trust_mark_template) data["sub"] = self.descendant.sub data["iss"] = self.issuer.sub data["iat"] = iat_now() # Draft 48: trust_mark_type; retrocompat: also set trust_mark_id data["trust_mark_type"] = self.profile.profile_id data.setdefault("trust_mark_id", self.profile.profile_id) return data @property def trust_mark_as_json(self): return json.dumps(self.trust_mark_as_dict) @property def trust_mark_as_jws(self): return create_jws( self.trust_mark_as_dict, self.issuer.jwks_fed[0], alg=self.issuer.default_signature_alg, typ="trust-mark+jwt" ) @property def trust_mark(self): # Draft 48: trust_mark_type; retrocompat: also trust_mark_id and id return { "trust_mark_type": self.profile.profile_id, "trust_mark": self.trust_mark_as_jws, "trust_mark_id": self.profile.profile_id, "id": self.profile.profile_id, } def __str__(self): return f"{self.profile} [{self.descendant}]" class FederationDescendantContact(TimeStampedModel): """ Federation OnBoarding entries. """ entity = models.ForeignKey( FederationDescendant, on_delete=models.CASCADE, help_text=_("Entity for which this contac is related"), ) contact = models.CharField( max_length=255, blank=False, null=False, help_text=_("any kind of contact type, usually an email."), ) type = models.CharField( max_length=255, blank=False, null=False, choices=[(i, i) for i in ("email", "telephone", "other")], ) class Meta: verbose_name = "Federation Entity Contact" verbose_name_plural = "Federation Entity Contacts" def __str__(self): return f"{self.contact} {self.entity.sub}" # signal on each save # def trust_chain_trigger(**kwargs): # subject = kwargs['instance'].sub # onboarding uses the first available configuration of federation_entity # fe = FederationEntityConfiguration.objects.filter( # is_active=True, # ).first() # logger.info( # f"Receiving trust chain evaluation signal for {subject}" # ) # return trust_chain_builder(subject) # post_save.connect(trust_chain_trigger, sender=FederationDescendant) #