"""Schema for Identity.""" from marshmallow import ValidationError, fields, pre_load, validates_schema from marshmallow.validate import Length, OneOf from permissions.constants import application, constants, parent_companies, vendor_star from permissions.utils.email import validate_auth0_email_length from permissions.validations import ma from permissions.validations.schemas.tenant_access_check import TenantAccessCheckSchema class Identity(ma.Schema): """Schema for Identity. Properties: - name (str): User name. - email (str): Email address. - first_name (str): User's first name. - last_name (str): User's last name """ name = fields.Str(required=True) email = fields.Email(required=True) first_name = fields.String(required=False) last_name = fields.String(required=False) user_type = fields.Str(validate=OneOf(constants.IDENTITY_USER_TYPE)) localization = fields.Str(validate=OneOf(constants.LOCALES.values())) number_format = fields.Str(validate=OneOf(constants.NUMBER_FORMAT)) @pre_load def lowercase_email(self, in_data, **kwargs): """Convert email to lowercase and strip spaces.""" if in_data.get('email'): in_data['email'] = in_data['email'].lower().strip().replace(' ', '') for key in ['name', 'first_name', 'last_name']: if in_data.get(key): in_data[key] = in_data[key].strip() return in_data class CreateIdentity(ma.Schema): """Schema for creating an identity via the v2/identities endpoint. Properties: - first_name (str): User's first name. - last_name (str): User's last name. - email (str): Email address. - roles_to_attach (list): Roles contains the list of RoleV2 to add to the identity's roles for the tenant. - tenant (dict): A tenant with a type and uuid. - master_contact (bool): Master contact flag. - send_invite (bool): Determine if we should create user in auth0 or just Neo4j. - localization (str): Locale to use for the user. """ first_name = fields.String(required=True) last_name = fields.String(required=True) email = fields.Email(required=True, validate=validate_auth0_email_length) roles_to_attach = fields.List( fields.String(validate=OneOf(application.ROLE_IDS_LIST)), required=True ) tenant = fields.Nested(TenantAccessCheckSchema, required=True) master_contact = fields.Bool(required=False, load_default=False) send_invite = fields.Bool(required=False, load_default=True) localization = fields.String( required=False, load_default=None, validate=OneOf(constants.LOCALES.values()) ) @pre_load def lowercase_email(self, in_data, **kwargs): """Convert email to lowercase and strip spaces.""" if in_data.get('email'): in_data['email'] = in_data['email'].lower().strip().replace(' ', '') for key in ['first_name', 'last_name']: if in_data.get(key): in_data[key] = in_data[key].strip() return in_data @validates_schema def validate_roles_supported_by_tenant(self, data, **kwargs): """Validate that roles are supported by the tenant type.""" _validate_roles_supported_by_tenant( roles=data['roles_to_attach'], tenant_type=data['tenant']['tenant_type'] ) class SeatTenant(ma.Schema): """Schema for validating seat tenant in create/update internal identity request body.""" tenant_type = fields.Str(validate=OneOf(constants.SEAT_ASSIGNABLE_TENANT_TYPES), required=True) tenant_uuid = fields.Str(required=True) @validates_schema def validate_tenant_uuid(self, data, **kwargs): """Validate tenant uuid if tenant type is parent company.""" if data['tenant_type'] == 'parent_company': if data['tenant_uuid'] not in parent_companies.PARENT_COMPANY_UUIDS: raise ValidationError( f"Tenant uuid {data['tenant_uuid']} " 'is not a valid parent company uuid.' ) class CreateInternalIdentity(ma.Schema): """Schema for creating an internal identity via the /internal/v2/identities endpoint. Properties: - first_name (str): User's first name. - last_name (str): User's last name. - email (str): Email address. - tenant (dict): A tenant with a type and uuid. - brand (str): Brand experience used for email customization. - roles_to_attach (list): Roles to add to the identity for the tenant. """ first_name = fields.String(required=True) last_name = fields.String(required=True) email = fields.Email(required=True, validate=validate_auth0_email_length) roles_to_attach = fields.List( fields.String(validate=OneOf(application.SEAT_ASSIGNABLE_ROLES)), required=True, validate=Length(min=1), ) brand = fields.String( validate=OneOf(constants.PROFILE_BRANDS), load_default=None, allow_none=True ) tenant = fields.Nested(SeatTenant, required=True) @pre_load def lowercase_email_and_strip_spaces_from_names(self, in_data, **kwargs): """Convert email to lowercase and strip spaces from name values.""" if in_data.get('email'): in_data['email'] = in_data['email'].lower().strip().replace(' ', '') for key in ['first_name', 'last_name']: if in_data.get(key): in_data[key] = in_data[key].strip() return in_data @validates_schema def validate_email_domain(self, data, **kwargs): """Validate that the email domain is valid for employee identities.""" allowed_domains = vendor_star.ALLOWED_VENDOR_STAR_EMAIL_DOMAINS email_domain = data['email'].split('@')[-1] if email_domain not in allowed_domains: raise ValidationError( f'Email domain {email_domain} is not valid for employee identities.' ) @validates_schema def validate_roles_to_attach(self, data, **kwargs): _validate_roles_to_attach(data['tenant']['tenant_type'], data['roles_to_attach']) @validates_schema def validate_brand(self, data, **kwargs): """Validate that brand is required if tenant type is parent company.""" if data['tenant']['tenant_type'] == 'parent_company': if not data.get('brand'): raise ValidationError('Brand is required for parent company tenant type.') class UpdateInternalIdentity(ma.Schema): """Schema for updating an identity via PATCH v2/internal/identities/.""" identity_id = fields.String(required=True) roles_to_attach = fields.List( fields.String(validate=OneOf(application.SEAT_ASSIGNABLE_ROLES)), required=True ) roles_to_detach = fields.List( fields.String(validate=OneOf(application.SEAT_ASSIGNABLE_ROLES)), required=True ) tenant = fields.Nested(SeatTenant, required=True) brand = fields.String(validate=OneOf(constants.PROFILE_BRANDS), required=False) @validates_schema def validate_roles_to_attach(self, data, **kwargs): _validate_roles_to_attach(data['tenant']['tenant_type'], data['roles_to_attach']) @validates_schema def validate_roles(self, data, **kwargs): """Validate roles_to_attach and roles_to_detach params.""" _validate_role_lists( roles_to_attach=data.get('roles_to_attach'), roles_to_detach=data.get('roles_to_detach') ) class UpdateIdentity(ma.Schema): """Schema for updating an identity via the PATCH v2/identities/ route.""" identity_uuid = fields.String(required=True) roles_to_attach = fields.List( fields.String(validate=OneOf(application.ROLE_IDS_LIST)), required=True ) roles_to_detach = fields.List(fields.String(), required=True) tenant = fields.Nested(TenantAccessCheckSchema, required=True) @validates_schema def validate_roles(self, data, **kwargs): """Validate roles_to_attach and roles_to_detach params.""" _validate_role_lists( roles_to_attach=data.get('roles_to_attach'), roles_to_detach=data.get('roles_to_detach') ) _validate_roles_supported_by_tenant( roles=data.get('roles_to_attach'), tenant_type=data['tenant']['tenant_type'] ) class RevokeIdentityTenantAccess(TenantAccessCheckSchema): """Schema for validating identity and tenant parameters in path.""" identity_id = fields.Str(required=True) class RevokeInternalIdentityTenantAccess(ma.Schema): """Schema for the internal revoke-tenant-access endpoint's path parameters. Only the account tenant type is supported by this endpoint. """ identity_id = fields.Str(required=True) tenant_type = fields.Str(validate=OneOf([constants.ACCOUNT_TENANT_TYPE]), required=True) tenant_uuid = fields.Str(required=True) def _validate_roles_to_attach(tenant_type: str, roles_to_attach: list[str]) -> None: """Validate that roles are supported by the tenant type.""" match tenant_type: case 'parent_company': allowed_roles = application.ROLES_SUPPORTED_BY_PARENT_COMPANY case 'account': allowed_roles = application.SEAT_ACCOUNT_ASSIGNABLE_ROLES invalid_roles = set(roles_to_attach) - set(allowed_roles) if invalid_roles: raise ValidationError( f"Roles not supported by the {tenant_type} tenant type:" f' {", ".join(sorted(invalid_roles))}' ) def _validate_role_lists(roles_to_attach: list[str], roles_to_detach: list[str]): """Validate roles_to_attach and roles_to_detach params.""" if not roles_to_attach and not roles_to_detach: raise ValidationError('Must provide at least one role to attach or detach.') intersection = set(roles_to_attach or []) & set(roles_to_detach or []) if intersection: raise ValidationError(f'Role(s) present in both attach and detach lists: {intersection}') def _validate_roles_supported_by_tenant(roles: list[str], tenant_type: str): match tenant_type: case 'account': for role in roles: if role not in application.ROLES_SUPPORTED_BY_ACCOUNT: raise ValidationError( f'Role {role} is not supported by the {tenant_type} tenant type.' ) case 'subaccount': for role in roles: if role not in application.ROLES_SUPPORTED_BY_SUBACCOUNT: raise ValidationError( f'Role {role} is not supported by the {tenant_type} tenant type.' ) case 'collaborator': for role in roles: if role not in application.ROLES_SUPPORTED_BY_COLLABORATOR: raise ValidationError( f'Role {role} is not supported by the {tenant_type} tenant type.' ) case 'label_participant': for role in roles: if role not in application.ROLES_SUPPORTED_BY_LABEL_PARTICIPANT: raise ValidationError( f'Role {role} is not supported by the {tenant_type} tenant type.' )