Skip to content

audit_trail.privacy

GDPR operations: scrub and scrub_actor.

These are the only updates the library ever makes to audit rows. Each one runs in the caller's transaction on a Connection and records itself as an audit.scrubbed entry in that same transaction, so the erasure and its record are committed together or not at all. The entry holds what was scrubbed and how many rows changed, never an erased value.

ACTOR_FIELDS module-attribute

ACTOR_FIELDS: tuple[str, ...] = (
    "actor_label",
    "remote_addr",
    "user_agent",
    "meta",
)

Columns of audit_transaction and keys of data.context that scrub_actor clears.

ScrubNotAllowedError

Bases: RuntimeError

scrub or scrub_actor was called without allow_scrub=True.

ScrubResult

Bases: NamedTuple

Rows a scrub changed.

Attributes:

Name Type Description
activity_rows int

audit_activity rows updated.

transaction_rows int

audit_transaction rows updated (always 0 for scrub).

ScrubbedRecord

Bases: NamedTuple

What the audit.scrubbed entry of a scrub is built from.

Attributes:

Name Type Description
severity int

Severity of the entry (the registry's system_severity).

context AuditContext

Context of whoever runs the scrub.

json_encoder type[JSONEncoder] | None

Host encoder for extra values of the context.

check_allowed

check_allowed(allow_scrub: bool) -> None

Refuse a scrub unless it was enabled.

Parameters:

Name Type Description Default
allow_scrub bool

The AuditTrail setting.

required

Raises:

Type Description
ScrubNotAllowedError

allow_scrub is False.

Source code in audit_trail/privacy.py
def check_allowed(allow_scrub: bool) -> None:
    """Refuse a scrub unless it was enabled.

    Args:
        allow_scrub: The ``AuditTrail`` setting.

    Raises:
        ScrubNotAllowedError: ``allow_scrub`` is ``False``.
    """
    if not allow_scrub:
        raise ScrubNotAllowedError(
            "scrub needs AuditTrail(allow_scrub=True) and an engine whose role "
            "may update the audit tables"
        )

check_since

check_since(since: datetime | None) -> None

Refuse a naive since, which PostgreSQL would read in the session's zone.

Parameters:

Name Type Description Default
since datetime | None

The lower bound of created_at, or None.

required

Raises:

Type Description
ValueError

since has no time zone.

Source code in audit_trail/privacy.py
def check_since(since: datetime | None) -> None:
    """Refuse a naive ``since``, which PostgreSQL would read in the session's zone.

    Args:
        since: The lower bound of ``created_at``, or ``None``.

    Raises:
        ValueError: ``since`` has no time zone.
    """
    if since is not None and since.utcoffset() is None:
        raise ValueError("since must be timezone-aware")

check_scope_ids

check_scope_ids(scope_ids: Collection[str] | None) -> None

Refuse a str as scope_ids, which would be read as its letters.

Parameters:

Name Type Description Default
scope_ids Collection[str] | None

The scope restriction, or None.

required

Raises:

Type Description
TypeError

scope_ids is a str.

Source code in audit_trail/privacy.py
def check_scope_ids(scope_ids: Collection[str] | None) -> None:
    """Refuse a ``str`` as ``scope_ids``, which would be read as its letters.

    Args:
        scope_ids: The scope restriction, or ``None``.

    Raises:
        TypeError: ``scope_ids`` is a ``str``.
    """
    _check_collection(scope_ids, "scope_ids")

scrub_context

scrub_context(
    context_provider: Callable[[], AuditContext | None]
    | None,
) -> AuditContext

Return the context of whoever runs a scrub; there is no session.

Parameters:

Name Type Description Default
context_provider Callable[[], AuditContext | None] | None

The host hook, checked before the context variable.

required

Returns:

Type Description
AuditContext

The provided context, else the active context(), else an

AuditContext

anonymous one.

Source code in audit_trail/privacy.py
def scrub_context(
    context_provider: Callable[[], AuditContext | None] | None,
) -> AuditContext:
    """Return the context of whoever runs a scrub; there is no session.

    Args:
        context_provider: The host hook, checked before the context variable.

    Returns:
        The provided context, else the active ``context()``, else an
        anonymous one.
    """
    if context_provider is not None:
        provided = context_provider()
        if provided is not None:
            return provided
    active = current_context()
    return AuditContext() if active is None else active

scrub_statement

scrub_statement(
    tables: AuditTables,
    object_type: str,
    object_id: str,
    *,
    include_targets: bool,
    since: datetime | None,
    scope_ids: Collection[str] | None = None,
) -> Update

Build the UPDATE that erases the entries of one object.

Parameters:

Name Type Description Default
tables AuditTables

The audit tables.

required
object_type str

Stored object_type of the object.

required
object_id str

Stored object_id of the object.

required
include_targets bool

Also erase entries whose target is the object.

required
since datetime | None

Only entries with created_at >= since; None for all.

required
scope_ids Collection[str] | None

Only entries whose scope_id is one of these; None for all, empty for none. Entries without a scope never match a restriction.

None

Returns:

Type Description
Update

The statement; its rowcount is the number of rows erased.

Source code in audit_trail/privacy.py
def scrub_statement(
    tables: AuditTables,
    object_type: str,
    object_id: str,
    *,
    include_targets: bool,
    since: datetime | None,
    scope_ids: Collection[str] | None = None,
) -> Update:
    """Build the ``UPDATE`` that erases the entries of one object.

    Args:
        tables: The audit tables.
        object_type: Stored ``object_type`` of the object.
        object_id: Stored ``object_id`` of the object.
        include_targets: Also erase entries whose target is the object.
        since: Only entries with ``created_at >= since``; ``None`` for all.
        scope_ids: Only entries whose ``scope_id`` is one of these; ``None``
            for all, empty for none. Entries without a scope never match a
            restriction.

    Returns:
        The statement; its rowcount is the number of rows erased.
    """
    c = tables.activity.c
    erased = literal_column(_ERASED_DATA, JSONB)
    match = and_(c.object_type == object_type, c.object_id == object_id)
    if include_targets:
        match = or_(match, and_(c.target_type == object_type, c.target_id == object_id))
    conditions = [match, c.verb.not_in(_LIBRARY_VERBS)]
    if since is not None:
        conditions.append(c.created_at >= since)
    in_scope = _member_of(c.scope_id, scope_ids)
    if in_scope is not None:
        conditions.append(in_scope)
    # Rows already erased are left alone, so a repeated scrub changes nothing.
    conditions.append(or_(c.object_label.is_not(None), c.data.is_distinct_from(erased)))
    return (
        update(tables.activity)
        .where(*conditions)
        .values(object_label=null(), data=erased)
    )

scrub

scrub(
    connection: Connection,
    tables: AuditTables,
    object_type: str,
    object_id: str,
    *,
    include_targets: bool,
    since: datetime | None,
    scope_ids: Collection[str] | None,
    record: ScrubbedRecord,
) -> ScrubResult

Erase the values an object's entries hold, and record it.

In every matching audit_activity row (except the library's own audit.* entries), each non-null value in data.changes and data.payload becomes "[erased]", and object_label becomes NULL. Field keys stay, so the history still shows what changed; a null stays null. Column changes keep their [old, new] shape and relationship changes their added/removed lists, with every id erased; a payload value is erased whole, including anything nested in it. data.context is left to scrub_actor.

With scope_ids, only entries whose scope_id is one of them are erased: an empty collection erases nothing, and an entry without a scope never matches. The audit.scrubbed entry records the restriction.

Runs in the connection's current transaction; the caller commits.

Parameters:

Name Type Description Default
connection Connection

Connection whose role may update the audit tables.

required
tables AuditTables

The audit tables.

required
object_type str

Stored object_type of the object.

required
object_id str

Stored object_id of the object.

required
include_targets bool

Also erase entries whose target is the object, such as changes of its children.

required
since datetime | None

Only entries with created_at >= since; None for all.

required
scope_ids Collection[str] | None

Only entries with one of these scope_id values; None for no restriction.

required
record ScrubbedRecord

Builds the audit.scrubbed entry.

required

Returns:

Type Description
ScrubResult

The number of rows erased.

Raises:

Type Description
ValueError

The connection is in AUTOCOMMIT mode.

Source code in audit_trail/privacy.py
def scrub(
    connection: Connection,
    tables: AuditTables,
    object_type: str,
    object_id: str,
    *,
    include_targets: bool,
    since: datetime | None,
    scope_ids: Collection[str] | None,
    record: ScrubbedRecord,
) -> ScrubResult:
    """Erase the values an object's entries hold, and record it.

    In every matching ``audit_activity`` row (except the library's own
    ``audit.*`` entries), each non-null value in ``data.changes`` and
    ``data.payload`` becomes ``"[erased]"``, and ``object_label`` becomes
    ``NULL``. Field keys stay, so the history still shows what changed; a
    ``null`` stays ``null``. Column changes keep their ``[old, new]`` shape
    and relationship changes their ``added``/``removed`` lists, with every id
    erased; a payload value is erased whole, including anything nested in
    it. ``data.context`` is left to ``scrub_actor``.

    With ``scope_ids``, only entries whose ``scope_id`` is one of them are
    erased: an empty collection erases nothing, and an entry without a scope
    never matches. The ``audit.scrubbed`` entry records the restriction.

    Runs in the connection's current transaction; the caller commits.

    Args:
        connection: Connection whose role may update the audit tables.
        tables: The audit tables.
        object_type: Stored ``object_type`` of the object.
        object_id: Stored ``object_id`` of the object.
        include_targets: Also erase entries whose target is the object, such
            as changes of its children.
        since: Only entries with ``created_at >= since``; ``None`` for all.
        scope_ids: Only entries with one of these ``scope_id`` values;
            ``None`` for no restriction.
        record: Builds the ``audit.scrubbed`` entry.

    Returns:
        The number of rows erased.

    Raises:
        ValueError: The connection is in ``AUTOCOMMIT`` mode.
    """
    _check_transaction(connection)
    statement = scrub_statement(
        tables,
        object_type,
        object_id,
        include_targets=include_targets,
        since=since,
        scope_ids=scope_ids,
    )
    erased = connection.execute(statement).rowcount
    _write_record(
        connection,
        tables,
        record.context,
        record,
        object_type=object_type,
        object_id=object_id,
        payload={
            "operation": "scrub",
            "object_type": object_type,
            "object_id": object_id,
            "include_targets": include_targets,
            "since": None if since is None else since.isoformat(),
            "scope_ids": _recorded(scope_ids),
            "activity_rows": erased,
        },
    )
    return ScrubResult(activity_rows=erased, transaction_rows=0)

scrub_actor

scrub_actor(
    connection: Connection,
    tables: AuditTables,
    actor_id: str,
    *,
    scope_ids: Collection[str] | None,
    record: ScrubbedRecord,
) -> ScrubResult

Clear an actor's personal context, and record it.

audit_transaction rows with this actor_id get actor_label, remote_addr, user_agent and meta set to NULL; the audit_activity rows with this actor_id lose those keys from data.context. actor_id itself stays everywhere.

A transaction row belongs to the actor it was created with: a request that wrote its first entry anonymously and then called set_actor keeps its address and user agent on that anonymous row.

With scope_ids, only activity rows whose scope_id is one of them are changed, and a transaction row only when it has activity rows and every one of them, whoever its actor, is in those scopes: its fields are shared by all its entries, so a request that also wrote entries of another scope, or without a scope, keeps them, as does a transaction row without entries. An empty collection changes nothing. Clearing an actor completely across scopes takes a run without scope_ids.

When the actor scrubs themself, the audit.scrubbed entry is written without those fields too.

Runs in the connection's current transaction; the caller commits.

Parameters:

Name Type Description Default
connection Connection

Connection whose role may update the audit tables.

required
tables AuditTables

The audit tables.

required
actor_id str

The actor's actor_id.

required
scope_ids Collection[str] | None

The scopes the scrub is restricted to; None for no restriction.

required
record ScrubbedRecord

Builds the audit.scrubbed entry.

required

Returns:

Type Description
ScrubResult

The numbers of rows changed.

Raises:

Type Description
ValueError

The connection is in AUTOCOMMIT mode.

Source code in audit_trail/privacy.py
def scrub_actor(
    connection: Connection,
    tables: AuditTables,
    actor_id: str,
    *,
    scope_ids: Collection[str] | None,
    record: ScrubbedRecord,
) -> ScrubResult:
    """Clear an actor's personal context, and record it.

    ``audit_transaction`` rows with this ``actor_id`` get ``actor_label``,
    ``remote_addr``, ``user_agent`` and ``meta`` set to ``NULL``; the
    ``audit_activity`` rows with this ``actor_id`` lose those keys from
    ``data.context``. ``actor_id`` itself stays everywhere.

    A transaction row belongs to the actor it was created with: a request
    that wrote its first entry anonymously and then called ``set_actor``
    keeps its address and user agent on that anonymous row.

    With ``scope_ids``, only activity rows whose ``scope_id`` is one of them
    are changed, and a transaction row only when it has activity rows and
    every one of them, whoever its actor, is in those scopes: its fields are
    shared by all its entries, so a request that also wrote entries of
    another scope, or without a scope, keeps them, as does a transaction row
    without entries. An empty collection changes nothing. Clearing an
    actor completely across scopes takes a run without ``scope_ids``.

    When the actor scrubs themself, the ``audit.scrubbed`` entry is written
    without those fields too.

    Runs in the connection's current transaction; the caller commits.

    Args:
        connection: Connection whose role may update the audit tables.
        tables: The audit tables.
        actor_id: The actor's ``actor_id``.
        scope_ids: The scopes the scrub is restricted to; ``None`` for no
            restriction.
        record: Builds the ``audit.scrubbed`` entry.

    Returns:
        The numbers of rows changed.

    Raises:
        ValueError: The connection is in ``AUTOCOMMIT`` mode.
    """
    _check_transaction(connection)
    transaction = tables.transaction
    activity = tables.activity
    in_scope = _member_of(activity.c.scope_id, scope_ids)
    tx_conditions = [
        transaction.c.actor_id == actor_id,
        or_(*(transaction.c[name].is_not(None) for name in ACTOR_FIELDS)),
    ]
    activity_conditions = [activity.c.actor_id == actor_id]
    if in_scope is not None:
        tx_conditions.append(_all_entries_match(tables, in_scope))
        activity_conditions.append(in_scope)
    cleared = connection.execute(
        update(transaction)
        .where(*tx_conditions)
        .values({name: null() for name in ACTOR_FIELDS})
    ).rowcount

    keys = literal_column(_ACTOR_KEYS, ARRAY(Text))
    context = activity.c.data["context"]
    stripped = connection.execute(
        update(activity)
        .where(*activity_conditions, context.has_any(keys))
        .values(
            data=func.jsonb_set(
                activity.c.data,
                literal_column("'{context}'"),
                context.op("-", return_type=JSONB)(keys),
                false(),
                type_=JSONB,
            )
        )
    ).rowcount

    ctx = record.context
    if ctx.actor_id == actor_id:
        ctx = dataclasses.replace(
            ctx, actor_label=None, remote_addr=None, user_agent=None, extra={}
        )
    _write_record(
        connection,
        tables,
        ctx,
        record,
        object_type=None,
        object_id=None,
        payload={
            "operation": "scrub_actor",
            "actor_id": actor_id,
            "scope_ids": _recorded(scope_ids),
            "transaction_rows": cleared,
            "activity_rows": stripped,
        },
    )
    return ScrubResult(activity_rows=stripped, transaction_rows=cleared)