basis/components/objects.py
apeters 3f3a7ba2d5 -
Signed-off-by: apeters <apeters@korves.net>
2025-05-27 07:25:27 +00:00

404 lines
15 KiB
Python

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 "<unknown>")
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