Skip to content

symfonic.services.privacy.lineage

lineage

Scope lineage — the ancestor keys a subject tombstone has to reach.

Erasure is subtree-scoped: erase_subject, export_subject and confirm_absent all match with :meth:SubjectScope.narrows. A fence that looked its tombstone up by the exact scope key alone would therefore fence the tenant and leave every sub-tenant underneath it writable — and that is a resurrection path, not a rounding error. Erase acme, write at acme/team1, export acme, and the "deleted" subject reads back out.

So the tombstone lookup walks the scope's lineage. Two rules it obeys:

  • Segments, never string prefixes (SEC-TEN-1). acme-evil does not narrow acme; a startswith says it does, and that is a cross-tenant denial-of-service dressed as a privacy control.
  • Every spelling of a second-level segment. sub_tenant_id and namespace serialize to different scope keys for the same segments, and a request carrying both headers with the same value serializes to a third (tenant:child:child) — narrows treats all three as one scope. A tombstone published under any spelling has to fence the others, or the erasure predicate and the fence disagree about what was deleted, and the disagreement is one-directional: the writer whose spelling nobody enumerated is admitted, commits, and reads its "erased" subject straight back out.

The lineage is ordered most-specific first, so a caller that wants the nearest tombstone (or the nearest incomplete saga) takes the first hit.

scope_key_lineage

scope_key_lineage(scope_key: str) -> tuple[str, ...]

:func:scope_lineage for a raw key.

A key that is not a scope key gets a one-element lineage rather than an exception: the fence is a safety control, and refusing to answer "is this tombstoned?" for an odd key would fail open at every call site that only wanted a boolean.

Source code in src/symfonic/services/privacy/lineage.py
def scope_key_lineage(scope_key: str) -> tuple[str, ...]:
    """:func:`scope_lineage` for a raw key.

    A key that is not a scope key gets a one-element lineage rather than an
    exception: the fence is a safety control, and refusing to answer "is this
    tombstoned?" for an odd key would fail *open* at every call site that only
    wanted a boolean.
    """
    try:
        scope = SubjectScope.from_key(scope_key)
    except SubjectScopeError:
        return (scope_key,)
    return scope_lineage(scope)

scope_lineage

scope_lineage(scope: SubjectScope) -> tuple[str, ...]

scope's own key, then every ancestor key, most specific first.

Source code in src/symfonic/services/privacy/lineage.py
def scope_lineage(scope: SubjectScope) -> tuple[str, ...]:
    """``scope``'s own key, then every ancestor key, most specific first."""
    keys: list[str] = [scope.scope_key]
    segments = scope.segments
    for depth in range(len(segments), 0, -1):
        for key in _spellings(segments[:depth]):
            if key not in keys:
                keys.append(key)
    return tuple(keys)