summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/db/purge.py
blob: 02d2914c630de264f658fd02770b2ff1ca7641c6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
"""Deleting a group from the hub, completely.

One implementation for every caller: the owner deleting a group, an
administrator erasing the account that owns it, and the cleanup of groups no
node ever hosted. Each used to carry its own partial cascade, and a row still
pointing at a deleted group is not an orphan on PostgreSQL but a foreign-key
error — the deletion fails. SQLite, which the tests run on, does not enforce
foreign keys by default, so nothing showed it.

The tables are found from the schema rather than listed: every table with a
foreign key to `groups.id` is emptied of the group's rows, including one added
after this was written. A table whose rows must outlive the group goes in
`_DETACHED`, and has its reference set to null instead.

Nothing on a node is touched. The hub does not command those machines; the
administrator's route pushes a signed revocation to the connected ones.
"""

from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession

from meshbay_hub.db.models import Base, ContentReport, Group

# Rows that outlive their group, detached rather than deleted: a report is
# evidence about content, and it is still needed once the group is gone.
_DETACHED = {ContentReport.__tablename__}


def _referencing() -> list[tuple]:
    """(table, column) for every foreign key onto `groups.id`."""
    refs = []
    for table in Base.metadata.sorted_tables:
        for fk in table.foreign_keys:
            if fk.column.table.name == Group.__tablename__ and fk.column.name == "id":
                refs.append((table, fk.parent))
    return refs


async def purge_groups(db: AsyncSession, group_ids: list[str]) -> None:
    """Delete these groups and everything on the hub that points at them.

    The caller commits: this is one step of a larger transaction (an account
    erasure deletes its groups and then empties the account row).
    """
    if not group_ids:
        return
    for table, column in _referencing():
        if table.name in _DETACHED:
            await db.execute(update(table).where(column.in_(group_ids)).values({column.name: None}))
        else:
            await db.execute(delete(table).where(column.in_(group_ids)))
    await db.execute(delete(Group).where(Group.id.in_(group_ids)))