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.
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_querysetnarrows to the validated workspace before any object is fetched, so ownership is never checked after the fact.- Ownership from context
perform_createassigns 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.
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: Bearerheader 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