Skip to main content

NanoScraper · boundaries

Where the boundaries are, and what enforces them.

Workspace is the tenant security boundary. What follows is what makes that true in code — the checks, the defaults, and the four ways data is allowed to leave.

Properties, not the map. No internal address, container name, backup destination or recovery window appears on this page. Each of them is true, and each would be a starting point.

What the API answers

A record in your workspace
200
A record in another workspace
404
A record that never existed
404

Identical, on purpose

A 403 would confirm it exists. So the stricter answer is the one that tells you less.

01 · Request path

A workspace id selects. A membership authorises.

Every request that touches tenant data authenticates the user, resolves the named workspace, verifies an active membership joining the two, establishes a validated context, and only then queries inside it. A client sends that identifier; it never sends permission.

backend/apps/tenancy/permissions.py, backend/apps/tenancy/context.py, backend/apps/tenancy/services.py — pinned by backend/apps/tenancy/tests/test_isolation.py and backend/apps/tenancy/tests/test_tasks.py.

No membership
404, not 403. A 403 would confirm the workspace exists.
No workspace named
Denied outright. There is nothing whose existence could leak.
In the worker
The task carries the workspace id and actor as plain scalars. The membership is re-read on entry, and a revoked one refuses to run.
The lookup
Made inside the scoped queryset, so “does not exist” and “another tenant's” are indistinguishable from outside.
Fig. 01 — Tenant request pathscroll sideways for the full width
A request carries a session cookie and a workspace id, and the id is only a selector. The server authenticates the user, resolves the named workspace, then checks for an active membership joining that user to that workspace — the step that authorises. Only then is a validated tenant context established and the queryset scoped before any lookup. A request naming no workspace is denied outright; a request naming a workspace with no active membership gets a 404 rather than a 403, because a 403 would confirm the workspace exists. A second lane shows the same boundary re-established in the worker: the task carries the workspace id and actor as plain values, the membership is re-read on entry, and the run is looked up inside the scoped queryset so that missing and belonging-to-another-tenant are indistinguishable.

The membership check is the only step that authorises. Everything before it is input, including the workspace id the caller chose — and the worker repeats the check on entry.

The exact scope of that claim

The check on entry is tested; the retry path is not exercised end to end. So nothing here is claimed about a redelivery nobody has watched happen.

02 · Scoping

Scoped by construction, not by remembering.

Tenant-owned viewsets inherit one mixin rather than each endpoint filtering for itself. The unsafe version is not something a new endpoint can reach by forgetting a line.

Scope, then look up
get_queryset narrows to the validated workspace before any object is fetched, so ownership is never checked after the fact.
Ownership from context
perform_create assigns the workspace server-side. Nothing a client submits can create a record into another tenant, or move one.
Fails closed
With no validated context the queryset method raises. Not an unfiltered set, and not an empty one — an empty result looks like an answer.

Two exceptions, stated rather than glossed

Template and marketplace listing reads deliberately widen to “your workspace, plus the shared catalogue”; writes still scope strictly. Two moderation endpoints read across every workspace by design, gated on a staff account.

So the precise claim: no tenant endpoint reads across workspaces, and the operator-only ones that do are unreachable with a tenant account.

backend/apps/catalog/views.py

03 · What leaves

Four ways out, and a gate on each.

Fig. 02 — What leaves a workspacescroll sideways for the full width
Four ways data leaves a workspace, each through its own gate. Export checks the quota before handing over any rows and refuses rather than returning a partial file, and neutralises spreadsheet formula cells. Delivery writes a durable attempt record before sending, re-validates the destination on every redirect hop and pins the connection to the address it checked, and signs the payload when a signing secret is set. The client portal requires a bearer key rather than a key in a web address, narrows to an allow-list of columns, and re-reads the dataset's distribution policy on every request. A read connection uses the same key handling and the same per-request re-check. On the last two, an excluded column is absent rather than redacted, so a hidden record and a missing record look identical.

Every path is gated before the bytes exist, not after. A check that runs once the file is built has already done the expensive, revealing half of the work.

  • Export

    Quota admission runs before any file bytes exist: an over-limit export raises and no partial file is returned — a test asserts the refusal carries no spreadsheet content type at all.

    Cells opening with a character a spreadsheet would treat as a formula are escaped first, proven by exporting a company literally named =cmd|'/c calc'!A1.

  • Delivery

    A durable attempt record is written before anything is sent, so a delivery that dies mid-flight still exists to inspect. The destination goes through the collector's URL policy: validated on every redirect hop, connected to the address that was checked rather than re-resolved, and method-changing redirects refused.

    Signatures cover the timestamp as well as the body. Client-facing refusals are one fixed sentence, so the wording cannot be diffed into a map of what is reachable.

  • Client portal, and the read connection

    The key arrives in an Authorization: Bearer header and nowhere else — a key in a web address lands in browser history, in referrer headers and in every access log between here and there. The distribution policy is re-read from the dataset on every request, not once at share time.

    An excluded column is absent rather than redacted — in the records, the change feed and the proof payload alike. A withheld record and a record that does not exist return the same body.

  • Credentials

    Every key in the sharing surface inherits one abstract base storing a lookup prefix and a digest, never the value itself. The full key is returned once at issue time and cannot be recovered; verification is a constant-time comparison against the digest.

    The prefix identifies the row, so a credential is never a primary key.

04 · Off by default

Everything outward-facing starts off.

Permission to collect something is not permission to redistribute it, and the defaults say so before an operator says anything.

Dataset distribution
Internal
A dataset is not shareable until someone changes that.
Marketplace listing
Draft
Nothing reaches the shared catalogue by being created.
Custom web address
Unproven
Starts as waiting for you to prove you own it, and the flow has four states rather than two.
Activation
Operator
Only for an address whose ownership is already proven. A successful verification moves one to awaiting setup and never to active, because activation asserts something about infrastructure no customer is positioned to know.

05 · Refused by design

Four things are refused by construction.

A capability that does not exist cannot be misconfigured. These four were left out on purpose, and the models carry no field that could hold them.

  • Customer stylesheet, markup or script

    Branding is data — a name, a few colours, a support address. Text containing angle brackets is refused rather than sanitised. A stylesheet could reposition a confirmation button; customer script is cross-site scripting we would have built ourselves.

  • An unreadable palette

    Colours are checked against WCAG AA at the API boundary — 4.5:1 for body text, 3:1 for interface colours — and an unreadable one is refused with a 400 rather than saved with a warning. Submitted colours merge over stored ones, so a palette cannot be degraded one field at a time.

  • An executable rule language

    The rule language is declarative data, bounded in nesting depth and node count, with patterns bounded in length and wildcard count. Matching is a hand-written two-pointer glob rather than a regular expression, so a crafted pattern cannot wedge a worker, and evaluation returns why it matched in the user's own vocabulary.

  • An open workflow vocabulary

    The step vocabulary is closed, and validation happens before storage: what runs is the checked version, not the submitted one.

06 · What this is not

What this describes, and what it does not certify.

Every claim above is a property of code — and that is exactly its limit.

There has been no external audit and no third-party test of any kind; the only adversarial review this code has had was internal. What has actually been executed, and what it does not establish, is on /docs/verification.

Outbound fetching, the URL policy, DNS rebinding and redirect handling are the other half of this subject and live on /docs/architecture, with the pipeline stages that use them.

The property worth stating here: the collection engines run with no route to the database or the broker, asserted against the declared configuration and against the live network from inside the engine — because a container attached from another project is invisible to a static check.

scripts/check_topology.py