from datetime import date, datetime, timedelta from enum import Enum from hashlib import md5 from flask import current_app from sqlalchemy import and_, case, text, or_, select, any_ from sqlalchemy import func from sqlalchemy.orm import column_property from sqlalchemy.ext.hybrid import hybrid_property from atlas_um.logs import logger from atlas_um.tokens.claims_serializer import ClaimsSerializer from atlas_um.settings import Settings from .associations import dna_account_claim_table, dna_account_tag_table from .base import pgdb from .claim_name import ClaimName from .claim_value import ClaimValue from .internal_user_domain import InternalUserDomain from .queries.dna_account import DNAAccountQuery from .resource_group import ResourceGroup from .views import ( dna_accounts_enabled_resource_groups, dna_accounts_global_claims, ) class DNAAccountStatuses(Enum): ACTIVE = "active" SUSPENDED = "suspended" PENDING = "pending" INVITED = "invited" def __str__(self): return self.value def __eq__(self, other): if isinstance(other, str): return self.value == other return super().__eq__(other) class DNAAccount(pgdb.Model): __tablename__ = "dna_account" query_class = DNAAccountQuery SUB_PREFIX = "sme-dna|" id = pgdb.Column(pgdb.Integer, primary_key=True) sub = pgdb.Column(pgdb.String(), nullable=False) usm_account = pgdb.Column(pgdb.String(), nullable=True) created_at = pgdb.Column( pgdb.DateTime, nullable=False, server_default=text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), ) updated_at = pgdb.Column( pgdb.DateTime, nullable=False, server_default=text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), ) business_unit_id = pgdb.Column( pgdb.Integer, pgdb.ForeignKey("business_unit.id") ) job_category_id = pgdb.Column( pgdb.Integer, pgdb.ForeignKey("job_category.id") ) personnel_type_id = pgdb.Column( pgdb.Integer, pgdb.ForeignKey("personnel_type.id") ) is_vip = pgdb.Column(pgdb.Boolean, nullable=False, default=False) expiration_date = pgdb.Column(pgdb.Date, nullable=True) is_sony_employee = pgdb.Column(pgdb.Boolean, nullable=False, default=False) supervisor_email = pgdb.Column(pgdb.String(), nullable=False, default="") supervisor_name = pgdb.Column(pgdb.String(), nullable=False, default="") no_mfa = pgdb.Column(pgdb.Boolean, nullable=False, default=False) job_title = pgdb.Column(pgdb.String(), nullable=False, default="") location = pgdb.Column(pgdb.String(), nullable=False, default="") preferred_username = pgdb.Column(pgdb.String(), nullable=False, default="") email = pgdb.Column(pgdb.String()) given_name = pgdb.Column(pgdb.String(), nullable=False, default="") family_name = pgdb.Column(pgdb.String(), nullable=False, default="") invitation_is_sent = pgdb.Column( pgdb.Boolean, nullable=False, default=False ) token_length = pgdb.Column(pgdb.Integer(), default=0) is_external = column_property( func.split_part(email, "@", 2).notin_( select([InternalUserDomain.domain]).select_from(InternalUserDomain) ) ) # Relations claim_values = pgdb.relationship( "ClaimValue", secondary=dna_account_claim_table, back_populates="dna_accounts", ) business_unit = pgdb.relationship( "BusinessUnit", back_populates="dna_accounts" ) job_category = pgdb.relationship( "JobCategory", back_populates="dna_accounts" ) personnel_type = pgdb.relationship( "PersonnelType", back_populates="dna_accounts" ) enabled_resource_groups = pgdb.relationship( "ResourceGroup", secondary=dna_accounts_enabled_resource_groups, primaryjoin=( dna_accounts_enabled_resource_groups.c.dna_account_id == id ), secondaryjoin=( ResourceGroup.id == dna_accounts_enabled_resource_groups.c.resource_group_id ), viewonly=True, order_by="ResourceGroup.name", ) applications = pgdb.relationship( "Application", back_populates="dna_account" ) tags = pgdb.relationship( "Tag", secondary=dna_account_tag_table, back_populates="dna_accounts", ) account_activities = pgdb.relationship( "DNAAccountActivity", back_populates="dna_account" ) external_states = pgdb.relationship( "DNAAccountExternalState", back_populates="dna_account" ) def __repr__(self): return f"" def __str__(self): return self.sub @classmethod def selsert_from_usm_claimset(cls, usm_claimset): emails = [usm_claimset["email"]] emails.extend(usm_claimset.get("verified_primary_email") or []) dna_account = cls.query.filter( or_( cls.usm_account == usm_claimset["sub"], cls.email.ilike(any_(emails)), ) ).first() if dna_account is None: return NullDNAAccount() if not dna_account.usm_account: dna_account.usm_account = usm_claimset["sub"] if not dna_account.sub: dna_account.sub = cls.generate_sub(dna_account.email) dna_account.preferred_username = ( usm_claimset.get("preferred_username") or dna_account.preferred_username ) dna_account.given_name = ( usm_claimset.get("given_name") or dna_account.given_name ) dna_account.family_name = ( usm_claimset.get("family_name") or dna_account.family_name ) return dna_account @staticmethod def generate_sub(id_value): id_part = md5(id_value.encode()).hexdigest() # nosec return DNAAccount.SUB_PREFIX + id_part def serialize_claims(self, resource_group=None, claim_name=None): claims = {"sub": self.sub, "email": self.email, "name": self.name} results = self.fetch_claims( only_active=True, resource_group=resource_group, claim_name=claim_name, ) claims.update(ClaimsSerializer.serialize(results)) return claims def fetch_claims( self, only_active=False, resource_group=None, claim_name=None ): query = ( pgdb.session.query(ClaimName, ClaimValue) .select_from(DNAAccount) .join(dna_accounts_global_claims) .join(ClaimValue) .join( ClaimName, dna_accounts_global_claims.c.claim_name_id == ClaimName.id, ) .join(ResourceGroup) .filter(DNAAccount.id == self.id) .filter(ClaimValue.is_deleted == False) # noqa .filter(ClaimName.is_deleted == False) # noqa .filter(ResourceGroup.is_deleted == False) # noqa .order_by(ClaimName.id, ClaimValue.friendly) # noqa ) if only_active: query = query.filter( dna_accounts_global_claims.c.is_disabled == False # noqa ) if resource_group: query = query.filter( ResourceGroup.id == resource_group.id, ) if claim_name: query = query.filter( or_( ClaimName.id == claim_name.id, dna_accounts_global_claims.c.claim_name_id == claim_name.id, ) ) return query @hybrid_property def name(self): return " ".join(n for n in [self.given_name, self.family_name] if n) @name.expression def name(cls): return case( ( and_( cls.given_name != "", cls.given_name != None, # noqa cls.family_name != "", cls.family_name != None, # noqa ), func.concat(cls.given_name, " ", cls.family_name), ), ( and_( cls.given_name != "", cls.given_name != None, # noqa ), cls.given_name, ), ( and_(cls.family_name != "", cls.family_name != None), # noqa cls.family_name, ), else_="", ) @hybrid_property def status(self): if self.expiration_date and self.expiration_date <= date.today(): return DNAAccountStatuses.SUSPENDED if not self.usm_account and self.invitation_is_sent: return DNAAccountStatuses.INVITED if not self.usm_account and not ( self.no_mfa and current_app.config.get("NO_MFA_ENABLED") ): return DNAAccountStatuses.PENDING return DNAAccountStatuses.ACTIVE @status.expression def status(cls): if current_app.config.get("NO_MFA_ENABLED"): usm_expression = and_( or_(cls.usm_account == None, cls.usm_account == ""), # noqa cls.no_mfa == False, # noqa ) else: usm_expression = or_( cls.usm_account == None, cls.usm_account == "" # noqa ) return case( ( func.date(cls.expiration_date) <= func.current_date(), DNAAccountStatuses.SUSPENDED.value, ), ( and_( cls.invitation_is_sent == True, # noqa or_( cls.usm_account == None, cls.usm_account == "" # noqa ), ), DNAAccountStatuses.INVITED.value, ), ( usm_expression, DNAAccountStatuses.PENDING.value, ), else_=DNAAccountStatuses.ACTIVE.value, ) @property def resource_groups_with_status(self): """Returns the query like: (ResourceGroup, is_disabled).""" subq = ( pgdb.session.query( ResourceGroup.id, func.coalesce( func.bool_or(dna_accounts_global_claims.c.is_disabled), True, ).label("is_disabled"), ) .select_from(ResourceGroup) .outerjoin(ClaimName) .join( dna_accounts_global_claims, and_( dna_accounts_global_claims.c.dna_account_id == self.id, dna_accounts_global_claims.c.claim_name_id == ClaimName.id, ), ) .group_by(ResourceGroup.id) .subquery() ) query = ( pgdb.session.query( ResourceGroup, func.coalesce(subq.c.is_disabled, True) ) .outerjoin(subq, ResourceGroup.id == subq.c.id) .filter(ResourceGroup.is_deleted == False) # noqa .order_by(ResourceGroup.name) ) return query @property def account_violates_limit(self): return self.token_length > current_app.config.get("MAX_TOKEN_LENGTH") @property def expires_soon(self): return ( self.expiration_date and self.expiration_date <= datetime.now().date() + timedelta(days=Settings.ACCOUNTS_EXPIRATION_NOTIFY_DAYS) and self.expiration_date > datetime.now().date() ) @property def last_login(self): return ( pgdb.session.query(func.max(DNAAccountActivity.last_login)) .filter(DNAAccountActivity.dna_account_id == self.id) .scalar() ) class NullDNAAccount(DNAAccount): # to inherit but not treat as real model __abstract__ = True id = None email = None @staticmethod def is_null(): return True @property def status(self): return None @property def enabled_resource_groups(self): return [] class DNAAccountActivity(pgdb.Model): __tablename__ = "dna_account_activity" id = pgdb.Column(pgdb.Integer, primary_key=True) dna_account_id = pgdb.Column( pgdb.Integer, pgdb.ForeignKey("dna_account.id") ) resource_group_id = pgdb.Column( pgdb.Integer, pgdb.ForeignKey("resource_group.id") ) first_login = pgdb.Column(pgdb.DateTime, nullable=True) last_login = pgdb.Column(pgdb.DateTime, nullable=True) last_activity = pgdb.Column(pgdb.DateTime, nullable=True) # Relations dna_account = pgdb.relationship( "DNAAccount", back_populates="account_activities", ) resource_group = pgdb.relationship( "ResourceGroup", back_populates="account_activities", ) @classmethod def register_for_resource_group( cls, dna_account, resource_group, commit=True ): instance = cls.query.filter( cls.dna_account_id == dna_account.id, cls.resource_group_id == (resource_group.id if resource_group else None), ).first() if not instance: instance = cls( dna_account=dna_account, resource_group=resource_group ) pgdb.session.add(instance) instance.last_login = datetime.now() instance.last_activity = datetime.now() if not instance.first_login: instance.first_login = datetime.now() if commit: pgdb.session.commit() return instance @classmethod def update_last_activity(cls, dna_account, resource_group, commit=True): instance = cls.query.filter( cls.dna_account_id == dna_account.id, cls.resource_group_id == (resource_group.id if resource_group else None), ).first() logger.bind( instance=instance, last_activity=instance.last_activity if instance else None, ).info("DEBUG update_last_activity") if instance: instance.last_activity = datetime.now() logger.bind( instance=instance, last_activity=instance.last_activity, ).info("DEBUG update_last_activity after") if commit: pgdb.session.commit() return instance class DNAAccountExternalState(pgdb.Model): __tablename__ = "dna_account_external_state" id = pgdb.Column(pgdb.Integer, primary_key=True) dna_account_id = pgdb.Column( pgdb.Integer, pgdb.ForeignKey("dna_account.id"), nullable=False ) source = pgdb.Column(pgdb.String, nullable=False) is_active = pgdb.Column(pgdb.Boolean, default=False, nullable=False) last_login = pgdb.Column(pgdb.DateTime, nullable=True) # Relations dna_account = pgdb.relationship( "DNAAccount", back_populates="external_states", )