Skip to content

effaced-fastapi

effaced-fastapi — mount effaced’s data-subject endpoints on a FastAPI app.

Wires the full effaced.EffacedStack from your annotated declarative base and exposes the trigger points — consent (Art. 7), export (Art. 15), erasure (Art. 17), and optionally restriction (Art. 18) — as one fastapi.APIRouter. Your auth stays yours: a dependency you provide resolves the request’s Subject.

The endpoints call the same engines, under the same audit and idempotency contracts, that you could wire by hand — this package only removes the mechanical wiring.

class ConsentRequest(BaseModel):
granted: bool
policy_version: str = Field(min_length=1, max_length=255)
purpose: str = Field(min_length=1, max_length=255)

What the caller decides about consent — nothing more.

The server fills in everything the caller must not control: the subject id comes from the authenticated Subject, the timestamp from the server clock, and the source is fixed to "api". The same body records a grant (granted=True) and a withdrawal (granted=False) — one call, per Art. 7(3)‘s “as easy to withdraw as to give”.

Fields:

  • granted (bool): True for a grant, False for a withdrawal.
  • policy_version (str): Version of the policy text the subject saw.
  • purpose (str): The processing purpose consented to (e.g. "newsletter").
class EffacedFastAPI:
def __init__(base: type[DeclarativeBase] | None = None, session_factory: sessionmaker | None = None, *, resolvers: Sequence[Resolver] = (), stack: EffacedStack | None = None) -> None

Mounts effaced’s data-subject endpoints on a FastAPI application.

Wires the full effaced.EffacedStack from your declarative base (or accepts one you prewired) and exposes the trigger points as a router. Every route is a plain def: FastAPI runs it on its threadpool, so the sync engines (ADR 0006) never block the event loop — and your subject provider may still be async.

The integration is deliberately thin: requests are served by the same engines, with the same audit, idempotency, and retention contracts, that you could call by hand. Route paths and response shapes are public API — the responses are the engines’ own result models, so what an endpoint returns changes only when the underlying engine’s behaviour does (widened SemVer).

Fields:

  • session_dependency (Callable[[], Iterator[Session]]): The default per-request transaction dependency (with session_factory.begin()). Built once, so it can be overridden by identity via app.dependency_overrides.
  • stack (EffacedStack): The wired engines, for direct calls beyond the endpoints.
def lifespan(*, poll_interval: float = 5.0) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]

Build a FastAPI lifespan that drains the outbox in the background.

Starts a SagaWorker (a daemon thread — never a task on the serving loop, ADR 0006) on startup and stops it on shutdown. FastAPI accepts a single lifespan: if your app already has one, construct the worker inside it instead.

Args:

  • poll_interval (float): Seconds the worker sleeps when the outbox comes back empty.

Returns:

  • Callable[[FastAPI], AbstractAsyncContextManager[None]] — A lifespan context manager for FastAPI(lifespan=...).
def router(subject: SubjectProvider, *, session: Callable[..., Iterator[Session]] | None = None, restriction: bool = False, tags: Sequence[str] = ('gdpr',)) -> APIRouter

Build the data-subject router around your subject provider.

Include it with a prefix that scopes it to the authenticated user, e.g. app.include_router(gdpr.router(subject=...), prefix="/me") — the erasure route lives at the prefix root (DELETE /me).

Default endpoints: POST /consent (record grant/withdrawal), GET /consent/{purpose} (current status), GET /export (Art. 15 bundle), and DELETE at the prefix root (Art. 17 erasure). Rectification ships no endpoint: which corrections a subject may self-serve is an authorization decision your application owns — call Rectifier.rectify_subject from your own route.

.. warning:: The router authorizes nothing — your subject dependency is the only access control. It authenticates no one and never checks that the caller may act on the resolved subject (ADR 0020): it exports or erases exactly the Subject the dependency returns. If that dependency reads an identifier the caller supplies (a header, path, query, or body field) without proving the authenticated caller is that subject — or is authorized to act on it — any caller can export or erase any subject (an insecure direct object reference). Resolve the subject from a verified session or token, and reject a request whose claimed subject does not match it.

Args:

  • subject (SubjectProvider): Dependency resolving the request’s Subject — your auth decides who the subject is and which external refs they carry. It is the trust boundary: it must prove the authenticated caller is (or may act on) the subject it returns (see the warning above).
  • session (Callable[..., Iterator[Session]] | None): Per-router override of session_dependency.
  • restriction (bool): Also expose POST /restriction and GET /restriction (Art. 18 flag-keeping, ADR 0014).
  • tags (Sequence[str]): OpenAPI tags applied to every route.

Returns:

  • APIRouter — The router, ready for app.include_router.
class RestrictionRequest(BaseModel):
purpose: str | None = Field(default=None, min_length=1, max_length=255)
reason: str | None = Field(default=None, max_length=255)
restricted: bool

What the caller decides about a restriction of processing (Art. 18).

The server fills in the subject id (from the authenticated Subject), the timestamp, and the "api" source. Recording is flag-keeping, never enforcement — nothing in effaced consults the flag before processing (ADR 0014).

Fields:

  • purpose (str | None): The processing purpose restricted; None means all processing for the subject.
  • reason (str | None): Free-text grounds (e.g. the Art. 18(1) basis claimed). Kept in the ledger’s history, never mirrored into audit payloads.
  • restricted (bool): True places a restriction, False lifts one.
class SagaWorker:
def __init__(runner: SagaRunner, *, poll_interval: float = 5.0) -> None

Drives SagaRunner.run_once on a daemon thread.

run_once makes blocking database calls between awaits, so it must never run on a serving event loop (ADR 0006). This worker packages the sanctioned alternative: a daemon thread running its own private asyncio loop, claiming batches until stopped and polling gently while the queue is empty.

A failing batch (database briefly down, resolver dependency unreachable) is logged and retried after the poll interval — the worker never dies silently, because a stalled drain loop means data-subject requests stop completing. Running several workers at once is safe: claiming uses FOR UPDATE SKIP LOCKED and crashed claims are re-claimed after the lease expires (ADR 0010).

def start() -> None

Start the drain thread; a no-op if it is already running.

def stop(*, timeout: float = 10.0) -> None

Signal the drain loop to exit and wait for the thread to finish.

The loop notices the signal at its next batch boundary or sleep wake-up, so stopping can take up to one poll interval.

Args:

  • timeout (float): Seconds to wait for the thread to join; the thread is a daemon, so a missed join never blocks interpreter exit. On a timed-out join the worker keeps its thread handle, so a subsequent start stays a no-op instead of spawning a second concurrent drain loop.
class Subject(BaseModel):
refs: tuple[SubjectRef, ...] = ()
subject_id: ValidatedSubjectId

Who the request is about — resolved by your auth, never by effaced.

The router never authenticates and never guesses where a subject lives outside the database: both are application knowledge. Your subject provider (any FastAPI dependency returning this model) supplies the subject id your models are keyed by and the external refs — e.g. the Stripe customer id you stored at signup — that route the subject’s export and erasure to registered resolvers.

Fields:

  • refs (tuple[SubjectRef, ...]): Where the subject lives in external systems; each ref’s kind names the resolver that handles it (ADR 0008).
  • subject_id (ValidatedSubjectId): The id the annotated models key the subject by — a single-column str or a composite CompositeSubjectId (ADR 0025), passed through to the engines unchanged.
SubjectProvider: TypeAlias = Callable[..., Subject] | Callable[..., Awaitable[Subject]]

A FastAPI dependency resolving the request’s Subject.

Sync or async, and free to depend on your own auth dependencies — a plain def route can depend on an async def provider; FastAPI resolves the dependency tree on the event loop either way.