from components.models.objects import ( ObjectIdList, model_classes, validate_call, UUID, Literal, ) from components.web.utils.quart import current_app, session from components.utils import ensure_list, merge_models from components.cache import buster from components.database import * @validate_call async def get( object_type: Literal[*model_classes["types"]], object_id: UUID | list[UUID], permission_validation=True, ): get_objects = ObjectIdList(object_id=object_id).object_id db_params = evaluate_db_params() if current_app and session.get("id"): user_id = session["id"] else: user_id = "99999999-9999-9999-9999-999999999999" if not user_id in IN_MEMORY_DB["CACHE"]["MODELS"]: IN_MEMORY_DB["CACHE"]["MODELS"][user_id] = dict() async with TinyDB(**db_params) as db: found_objects = db.table(object_type).search(Query().id.one_of(get_objects)) object_data = [] for o in found_objects: o_parsed = model_classes["base"][object_type].model_validate(o) if ( not "system" in session["acl"] and permission_validation == True and user_id not in o_parsed.details.assigned_users ): continue for k, v in o_parsed.details.model_dump(mode="json").items(): if k == "assigned_domain": if not v in IN_MEMORY_DB["CACHE"]["MODELS"][user_id]: IN_MEMORY_DB["CACHE"]["MODELS"][user_id][v] = await get( object_type="domains", object_id=v, permission_validation=False, ) o_parsed.details.assigned_domain = IN_MEMORY_DB["CACHE"]["MODELS"][ user_id ][v] elif k in ["assigned_arc_keypair", "assigned_dkim_keypair"] and v: if not v in IN_MEMORY_DB["CACHE"]["MODELS"][user_id]: IN_MEMORY_DB["CACHE"]["MODELS"][user_id][v] = await get( object_type="keypairs", object_id=v, permission_validation=False, ) setattr( o_parsed.details, k, IN_MEMORY_DB["CACHE"]["MODELS"][user_id][v] ) elif k == "assigned_emailusers" and v: o_parsed.details.assigned_emailusers = [] for u in ensure_list(v): if not u in IN_MEMORY_DB["CACHE"]["MODELS"][user_id]: IN_MEMORY_DB["CACHE"]["MODELS"][user_id][u] = await get( object_type="emailusers", object_id=u, permission_validation=False, ) o_parsed.details.assigned_emailusers.append( IN_MEMORY_DB["CACHE"]["MODELS"][user_id][u] ) object_data.append(o_parsed) if len(object_data) == 1: return object_data.pop() return object_data if object_data else None async def delete( object_type: Literal[*model_classes["types"]], object_id: UUID | list[UUID], ): delete_objects = [o for o in ensure_list(await get(object_type, object_id))] db_params = evaluate_db_params() if object_type == "domains": for o in delete_objects: addresses = await search(object_type="addresses", fully_resolve=False) if o.id in [address.details.assigned_domain for address in addresses]: raise ValueError("name", f"Domain {o.name} is not empty") async with TinyDB(**db_params) as db: buster([o.id for o in delete_objects]) return db.table(object_type).remove( Query().id.one_of([o.id for o in delete_objects]) ) @validate_call async def patch( object_type: Literal[*model_classes["types"]], object_id: UUID | list[UUID], data: dict, ): assert current_app and session.get("id") to_patch_objects = [o for o in ensure_list(await get(object_type, object_id))] db_params = evaluate_db_params() for to_patch in to_patch_objects: if not "system" in session["acl"]: if not "details" in data: data["details"] = dict() for f in model_classes["system_fields"][object_type]: data["details"][f] = getattr(to_patch.details, f) patch_data = model_classes["patch"][object_type].model_validate(data) patched_object = merge_models( to_patch, patch_data ) # returns updated to_patch model conflicts = await search( object_type=object_type, match_all={ f: getattr(patched_object.details, f) for f in model_classes["unique_fields"][object_type] }, fully_resolve=False, ) if [o.id for o in conflicts if o.id != patched_object.id]: raise ValueError( f"details.{model_classes['unique_fields'][object_type][0]}", "The provided object exists", ) if object_type == "domains": if "system" in session["acl"]: addresses_in_domain = await search( object_type="addresses", match_all={"assigned_domain": patched_object.id}, fully_resolve=False, ) if ( patched_object.details.n_mailboxes and len(addresses_in_domain) > patched_object.details.n_mailboxes ): raise ValueError( f"details.n_mailboxes", f"Cannot reduce allowed mailboxes below {len(addresses_in_domain)}", ) else: for attr in ["assigned_dkim_keypair", "assigned_arc_keypair"]: # keypairs default to "", verify if attr not in data.get("details", {}): continue patched_obj_keypair = getattr(patched_object.details, attr) patched_obj_keypair_id = ( patched_obj_keypair.id if hasattr(patched_obj_keypair, "id") else patched_obj_keypair ) to_patch_obj_keypair = getattr(to_patch.details, attr) to_patch_obj_keypair_id = ( to_patch_obj_keypair.id if hasattr(to_patch_obj_keypair, "id") else to_patch_obj_keypair ) if patched_obj_keypair_id != to_patch_obj_keypair_id: if not "system" in session["acl"]: if not await get("keypairs", to_patch_obj_keypair_id): raise ValueError( f"details.{attr}", f"Cannot unassign a non-permitted keypair", ) if not await get("keypairs", patched_obj_keypair_id): raise ValueError( f"details.{attr}", f"Cannot assign non-permitted keypair", ) if object_type == "addresses": if not "system" in session["acl"]: if ( patched_object.details.assigned_domain != to_patch.details.assigned_domain.id ): # only when assigned_domain changed if ( not len( await get( "domains", [ patched_object.details.assigned_domain, to_patch.details.assigned_domain.id, ], ) ) == 2 ): # disallow a change to a permitted domain if the current domain is not permitted raise ValueError( "details.assigned_domain", f"Cannot assign selected domain for object {to_patch.details.local_part}", ) if set(patched_object.details.assigned_emailusers) != set( [u.id for u in to_patch.details.assigned_emailusers] ): # only when assigned_emailusers changed non_permitted_users = set() for emailuser in [ *patched_object.details.assigned_emailusers, *[u.id for u in to_patch.details.assigned_emailusers], ]: _ = await get( object_type="emailusers", object_id=emailuser, permission_validation=False, ) if session["id"] not in _.details.assigned_users: non_permitted_users.add(_.name if _ else "") if non_permitted_users: # disallow a change to a permitted domain if the current domain is not permitted raise ValueError( "details.assigned_emailusers", f"You are not allow to change email user assignments for {', '.join(non_permitted_users)} of address {to_patch.details.local_part}", ) if ( patched_object.details.assigned_domain != to_patch.details.assigned_domain.id ): addresses_in_new_domain_now = await search( object_type="addresses", match_all={ "assigned_domain": patched_object.details.assigned_domain }, fully_resolve=False, ) new_domain_data = await get( object_type="domains", object_id=patched_object.details.assigned_domain, permission_validation=True, ) if ( new_domain_data.details.n_mailboxes and new_domain_data.details.n_mailboxes < (len(addresses_in_new_domain_now) + 1) ): raise ValueError( f"details.assigned_domain", "The domain's mailbox limit is reached", ) async with TinyDB(**db_params) as db: db.table(object_type).update( patched_object.model_dump( mode="json", exclude_none=True, exclude={"name", "id", "created"} ), Query().id == to_patch.id, ) buster([o.id for o in to_patch_objects]) return [o.id for o in to_patch_objects] async def create( object_type: Literal[*model_classes["types"]], data: dict, ): assert current_app and session.get("id") db_params = evaluate_db_params() if not "details" in data: data["details"] = dict() data["details"]["assigned_users"] = session["id"] create_object = model_classes["add"][object_type].model_validate(data) conflicts = await search( object_type=object_type, match_all={ f: getattr(create_object.details, f) for f in model_classes["unique_fields"][object_type] }, fully_resolve=False, ) if [o.id for o in conflicts]: raise ValueError( f"details.{model_classes['unique_fields'][object_type][0]}", "The provided object exists", ) if object_type == "addresses": if not "system" in session["acl"]: if not await get("domains", create_object.details.assigned_domain): raise ValueError("name", "The provided domain is unavailable") addresses_in_domain = await search( object_type="addresses", match_all={"assigned_domain": create_object.details.assigned_domain}, fully_resolve=False, ) domain_data = await get( object_type="domains", object_id=create_object.details.assigned_domain, permission_validation=True, ) domain_assigned_users = set(domain_data.details.assigned_users) domain_assigned_users.add(session["id"]) create_object.details.assigned_users = list(domain_assigned_users) if domain_data.details.n_mailboxes and domain_data.details.n_mailboxes < ( len(addresses_in_domain) + 1 ): raise ValueError( f"details.assigned_domain", "The domain's mailbox limit is reached", ) if object_type == "domains": if not "system" in session["acl"]: raise ValueError("name", "You need system permission to create a domain") async with TinyDB(**db_params) as db: insert_data = create_object.model_dump(mode="json") db.table(object_type).insert(insert_data) try: del IN_MEMORY_DB["CACHE"]["FORMS"][session.get("id")][object_type] finally: return insert_data["id"] async def search( object_type: Literal[*model_classes["types"]], object_id: UUID | None = None, match_all: dict = {}, match_any: dict = {}, fully_resolve: bool = False, ): db_params = evaluate_db_params() def search_object_id(s): return (object_id and str(object_id) == s) or not object_id def filter_details(s, _any: bool = False): def match(key, value, current_data): if key in current_data: if isinstance(value, list): return any(item in ensure_list(current_data[key]) for item in value) return value in current_data[key] for sub_key, sub_value in current_data.items(): if isinstance(sub_value, dict): if match(key, value, sub_value): # Recursive call return True return False if _any: return any(match(k, v, s) for k, v in match_any.items()) return all(match(k, v, s) for k, v in match_all.items()) query = Query().id.test(search_object_id) if match_all: query = query & Query().details.test(filter_details) if match_any: query = query & Query().details.test(filter_details, True) async with TinyDB(**db_params) as db: matches = db.table(object_type).search(query) if fully_resolve: return ensure_list( await get( object_type=object_type, object_id=[o["id"] for o in matches], permission_validation=False, ) or [] ) else: _parsed = [] for o in matches: _parsed.append(model_classes["base"][object_type].model_validate(o)) return _parsed