mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* fix(security): close 12 authorization holes found by adversarial round 1
Four offensive agents attacked the hub's trust boundaries in isolated
worktrees; every finding is a Go test that failed on the tree before the
fix and passes after. 43 TestSec_* regression tests land with the fixes.
The two that matter most:
- A grant outlived org membership: projectPerm consulted p.Perms before
checking the org role, so removing someone from the org through the API
left their explicit project grant working. Offboarding did not offboard.
- Any device could PUT any other device's journal key — the hub never
compared the key to X-Bdrive-Device. That is the "each device writes
only its own journal" invariant, enforced nowhere.
Also: uploads accepted .bdrive/ and .git/ paths (and materialize applied
only filter.Skip, never neverSync, so a hostile peer journal could too);
the org share audit handed every member public /s/ URLs for projects they
were denied; password reset left old sessions and device tokens valid;
blobs were not verified against their content address; quota was bypassed
by chunked encoding and by a client-declared size; X-Forwarded-For
defeated both rate limiters, login brute-force included; the /s/* sandbox
CSP was missing on error paths; expired shares were revocable by anyone;
and projectPerm failed open to admin for org-less and unknown projects.
Known open, recorded in .claude/security-goal.md: the device header is
self-asserted (the fix buys an audit trail, not identity), and the
Dir==nil/Auth==nil admin escape needs a design decision first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 17 more holes found by adversarial round 2
Round 2 attacked the five boundaries round 1 never reached (read heat,
path handling, secret leakage, the agent hook guard, the metadata store)
plus the places round 1's coverage was overstated. 43 more regression
tests; 86 TestSec_* now green.
The critical one was not on the original board. A journal op's Blob field
is a raw storage key that nothing validates: handleStorePut checks blob
keys and their content hash, but a journal is arbitrary JSONL that never
passes through that validator. One PUT of your own journal with
"blob":"../../<other-project>/blobs/<sha>" — or "../../../etc/passwd" —
then reads back through the ordinary /file, /download and /render routes.
Any member read any file on the hub host, across orgs and outside the
storage root. Guarded where content resolves, and localBackend now
refuses a key that escapes root at all.
Also: /blob served HTML and SVG inline on the hub origin with no sandbox
CSP (stored XSS via History); bdrive init deleted the hooks it had just
written whenever $HOME is a git repo, silently disabling sync hooks
machine-wide; any account could rewrite another org's device registry row
and forge History attribution; a planted device id became a heat "reader",
putting an identity in an API response that must never carry one; storage
errors relayed the hub's absolute paths (and on S3 the bucket and key);
/auth/login?next= was an open redirect via backslash and TAB; the org and
project registries handed out their live maps (self-promotion to owner,
plus a hub-killing concurrent map iteration); revoked invites came back
after a restart and refused writes applied in memory anyway; a share
minted by someone since removed from the org kept serving publicly;
a newline in a folder name made the hook guard spawn bdrive outside any
mount; single-volume upload escaped through a symlink; the seat check was
check-then-act; and the hub data dir holding auth.json ended up 0755.
Known open and recorded in .claude/security-goal.md: nothing expires (no
TTL on tokens or sessions), the Dir==nil/Auth==nil admin escape, and
client-asserted Op.User. Postgres was never exercised — row 14's
SQL-injection result covers file and sqlite only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 17 more holes found by adversarial round 3
Round 3 attacked the journal fields nobody had audited, the fixes rounds
1 and 2 had just landed, the real `bdrive serve -c` config path, and the
read ledger on a live Postgres. 133 TestSec_* now green.
The two worst are in the syncer, and both are arbitrary file write on
every teammate's machine. materialize guarded Path with neverSync, which
splits on "/" and looks segments up in ReservedDirs — ".." is not a
member and no ignore rule mentions it, so one JSONL line pushed to your
own journal key reached ~/.ssh/authorized_keys on every device that
synced. And the reserved-directory guard was case-sensitive, so
.GIT/hooks/pre-commit sailed past it and APFS resolved it into the real
.git/hooks. Op.Mode was applied verbatim, setuid bit included.
Attacking our own round-2 fixes paid for the slot twice over: ownsDevice
turned out to be a one-request speed bump (the refused report is what
registered the forged id, so the second identical request passed) and
its first-caller-wins rule made device registration a claim-staking
primitive an outsider could use to forge History attribution and
silently kill the real owner's read heat forever. Both dissolve by
keying the registry on (account, id) instead of treating the client's
device header as a hub-wide namespace. trust_proxy, added in round 2,
took the first X-Forwarded-For hop — but XFF grows left-to-right, so
turning it on disabled the login brute-force limiter instead of fixing
it.
Also: a peer's Lamport: MaxInt64 wrapped a victim's clock and silently
reverted its own edits on its own disk; History leaked other orgs'
device names and was a hub-wide device-existence oracle; anonymous
/api/config named the storage bucket; /auth/reset enumerated accounts by
timing and was not rate limited; the signed-in hub UI was frameable and
sniffable; an asset miss returned the app shell marked immutable for a
year; Op.Size forged Content-Length; a share on an org-less project
survived its creator's offboarding; a refused upload still created
directories outside the served folder; and one NUL in a read report
wedged the entire hub's read telemetry permanently on Postgres.
Verified against a real Postgres 16 this round. Known open and recorded
in .claude/security-goal.md: NUL round-trip on Postgres text columns
(refusal vs encoding is a design call), nothing expires, the
Dir==nil/Auth==nil escape, client-asserted Op.User, and store/sign on a
backend that can actually presign — never reached in three rounds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 32 more holes found by adversarial round 4
Round 4 opened five packages no previous round had touched — store,
journal, config, remote and cmd/bdrive — and attacked the fixes rounds
1-3 had landed. It broke five of them. 166 TestSec_* now green.
The four criticals:
- ownJournal bound the journal key to the X-Bdrive-Device header of the
same request, and nothing bound that header to an account. Round 1's
test varied the key while holding the header fixed — the one
combination that was refused. Move both together and any member wrote
and replaced any peer's journal: her ops vanish, every device replays
the attacker's deletes, History credits them to her.
- store.BlobPath joined Op.Blob straight onto the blob dir with no check
that it was a sha256, and HasBlob answering true made syncer.pull skip
hash verification — so a peer's journal op read any file on every
teammate's machine into their working folder.
- A folder's .bdrive/config.json chose where this device's hub token was
sent. The file travels with the folder, so a zip or a colleague's copy
redirected the credential to any host, http:// included.
- sync --prune read .bdriveignore before the cycle and pruned against the
version the cycle had just pulled, so it deleted for the whole team
under exactly the ! rules it refuses to run with. A teammate running
bdrive scope was enough; no attacker needed.
Also: two symlink escapes in materialize (unsafeRel judges spelling, not
disk); three ways one peer op killed sync permanently on every device
that pulled it, including a panic on a short blob string; the ignore-file
reload dropped the nested-mount boundary; round 3's Lamport ceiling was
inclusive and so still reachable; the (account, id) device rekey held
neither on the read path nor on disk, so a restart handed the device to
the squatter; presigned uploads bypassed the content-address guard
entirely — that whole branch had never executed under a test, since every
fixture used file:// which cannot sign; remote.Prefixed, the single
containment primitive for multi-tenancy, did not contain; and a mount id
from the untrusted folder config escaped $BDRIVE_HOME.
Plus: bdrive export wrote hub-named keys as tar members unvalidated; the
device token followed cross-origin redirects; a symlink in the file://
storage root read and wrote anywhere on the host; GCS presigned PUTs
bound no size; presigned device uploads were never billed; trust_proxy
was wrong for the third round running; one bad line voided an entire
journal; Op.Path was not byte-exact through JSON; Less was not a total
order, so Replay's determinism rested on a caller's accident; and client
journals were 0644.
Three new scoreboard rows: client local state, the project archive, and
the device as client of a hostile hub. Still zero tests after four
rounds: internal/daemon and internal/autostart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 29 more holes found by adversarial round 5
Round 5 opened internal/daemon and internal/autostart (zero tests after
four rounds), drove the browser presign flow, and attacked round 4's
fixes. It broke seven of them, including the one round 4's commit message
called "the critical". 247 TestSec_* now green.
ownJournal failed four separate ways. The write doors called
observeDevice BEFORE ownJournal, so for an unclaimed id the request
manufactured the fact that authorized it — and device ids are not secret,
History publishes them. The claim was then permanent and unrecoverable:
no Delete on DeviceRepo, no release, no admin route, no CLI re-mint, and
a 403 naming no remedy, so any member could deny any colleague's laptop
the ability to sync forever. Rows were global but visibility per project,
so offboarding a teammate released her journal to whoever was left.
And because an ownerless row ranked as the earliest claim, every hub
upgraded from before rows had owners had the binding switched off for
precisely its established devices. Ownership is now hub-wide, an
ownerless row authorizes nobody, observation happens after the decision,
and project admin is the documented recovery path.
The other critical is a divergence primitive: pull resumed at an op
COUNT, and round 4 had just taught Parse to skip bad lines silently. One
undecodable line inserted among lines a device already counted shifts
every appended op down by one, so two devices replaying one journal hold
different states permanently — and the peer picks the split. Now resumed
at a byte offset.
Also: a mid-run edit to .bdrive/config.json moved a whole project to a
remote of the writer's choosing, with no restart and no credential, and
the daemon then pulled from it; verify-on-read was defeated by uploading
honest bytes first, since a presigned URL is replayable for its TTL;
appendOp's lamport wrapped int64 and silently broke last-writer-wins for
every later upload in the project; peer journal strings reached bdrive
log's terminal unescaped, so the audited party could rewrite the audit
(OSC 52 to the clipboard, \r to repaint a delete as a put); path_raw let
one journal line name two different files to two reader versions; Stop
signalled whatever pid a 0644 file named; locked() failed open, so status
lied and stop stopped nothing; and a macOS path containing "&" made the
autostart plist unparseable while Install reported success.
Two four-round deferrals are now answered rather than carried.
Dir==nil/Auth==nil is not reachable — nine real configurations, both
arms, real project ids — and is a guarded invariant. The Postgres NUL
question was swept across seven stored-record surfaces on a live
Postgres: no silent-loss path, and cleanUploadPath now refuses control
characters so it is unreachable through the API.
Quota became a reservation with reconciliation: reserved at the grant so
concurrent grants cannot oversubscribe, charged on arrival, released free
on expiry, and charged once.
Recorded, not hidden: permHub builds a hub with Devices == nil, so round
4's ownership binding was inert in that fixture and earlier "clean"
results measured through it proved less than they looked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 30 more holes found by adversarial round 6
Round 6 attacked round 5's fixes, the completeness floor (templates, CLI
commands, the untested exported API), and — new this round — the test
suite itself. 290 TestSec_* now green.
One hacker reverted 33 of the accumulated fixes one at a time to see
which the suite actually caught. 28 held. Five did not, and those five
are the most valuable result of the round: permHub built its hub with
Devices == nil, so a dozen journal-pushing tests had been proving
permission and never ownership; the ownerless-legacy-row test passed
because its helper never set Op.Device, so the r5 hole it guards would
have gone undetected; nothing asserted that an outstanding presigned
grant counts against the quota cap, which is half of reserve.go's
contract; row 6 claimed cleanUploadPath refuses control characters and
named no test, and that guard is what keeps the Postgres NUL divergence
unreachable; and unsafeRel — round 3's headline client fix — could be
deleted with the suite green, surviving on round 4's UnderRoot. It also
accepted ".", contained only because hashFile happens to fail on a
directory first.
The criticals: round 5's byte-offset pull resume was the same divergence
primitive it replaced, twice. A peer that publishes in two stages and
cuts the first mid-line makes one chosen device permanently skip the op
that straddles the cut while every other device applies it. And round 5
deleted the shrink guard, so a peer withdraws an op every device already
applied — the file vanishes from teammates' folders with no delete op,
nothing in the journal, nothing in History. Separately, Deny removed an
account but every authorization decision downstream keys on email, so
grants and org roles stayed attached to the address: re-registering it
walked back in as project admin, and its public share links kept
serving.
Also: reset and verification mail took its link host from the request, so
an unauthenticated stranger could have the hub mail a victim a genuine
reset link pointing at the attacker's server; a refused password reset
reported "Password updated"; share revocation, approval, policy and
account removal all took effect in memory after the store refused them,
each failing in the widening direction; one journal push with a year-2300
timestamp overflowed the History cursor and hid the whole audit feed past
page one; account ids were 32 bits with no uniqueness check, and the
birthday bound is ~9,300 accounts for a 1% chance of silently
transferring one account's credentials onto another; the new reservation
ledger had a data race on the billing path, a check-then-act that let 5
of 16 concurrent grants through a cap fitting one, and released arrived
bytes unbilled on expiry; safeField stripped C0 but not the 8-bit C1
controls that are CSI/OSC/DCS/NEL in any xterm-lineage terminal, nor
bidi overrides; and internal/templates — first contact — bypassed
cleanUploadPath entirely and wrote through symlinks using the shipped
template with no hostile input at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 29 more holes found by adversarial round 7
Round 7 drove `bdrive init` end to end for the first time — named the
largest gap by two consecutive CISOs — and tripled the sabotage sweep to
53 reversions. 326 TestSec_* functions green.
init held two criticals the moment it was actually run. `init --server`
took the no-auth branch when a server answered {"auth":{"enabled":false}}
and rewrote settings.Server without touching settings.Token — and
settings.Server is the entirety of round 4's token binding, so a 30-line
HTTP server collects the real hub's bearer token. And `init $BDRIVE_HOME`
was accepted, because the .bdrive reserved-directory rule only applies to
segments below the mount root: from there settings.json is an ordinary
top-level file, so the first cycle pushed this device's token to the hub
as project content, for every member and every teammate's disk.
Two more criticals were old fixes on doors they never covered. Round 4
bound the device token to settings.Server's origin in remote.deviceToken
— the sync backend's door — while share.go reads its destination from the
folder's .bdrive/config.json and hands the token straight to it, and the
CLI's own http.Client had no CheckRedirect at all. And round 6's offboard
only log.Printf'd RemoveMember's "cannot remove the last owner", so
anyone signing up on a removed sole-owner's address inherited org
ownership and admin on every project in it.
The sabotage sweep found 8 more guards deletable with the whole suite
green, including MayActAs (every existing test planted an id that
validDeviceID rejects first, so the ownership loop was never consulted),
both framing headers (round 3's test held the disjunction, not the code),
and sqlAccountRepo's id guard — where the untested backend is the one
managed and Postgres deployments run. For the first time the sweep also
covered the three choke points themselves: reverting requirePerm turns 30
tests red, projectPerm 21, authGate 9.
Also: two more journal-undo primitives past round 6's count guard, now
keyed on identity; reset-mail poisoning survived because the pin was
first-request-wins and round 6's own reproducer sent the honest request
first; the /store/* journal door accepted paths /upload/commit refuses,
so the three spellings of one path rule became one exported predicate;
bdrive forget injected .bdriveignore rules outside any managed block;
bdrive resume built a volume path from an unvalidated registry key; round
5's $HOME-is-a-git-repo fix broke again on a string compare of two
spellings of one path, silently disabling every agent hook on the machine
while init reported success; and the CheckWrite call round 6 moved under
the hub-wide ledger mutex stalled every project's sync cycle.
Known open: a peer can still un-publish an applied op by corrupting its
line and appending as many as it removed — the clean close is hub-side
append-only enforcement on /store/*, a behaviour change no failing test
demands yet. The init tests all ran with auth.enabled false, so the login
flow inside init — where the first critical lives — is still untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 26 more holes found by adversarial round 8
Round 8 drove the authenticated login flow, the last five CLI commands,
and sabotaged row 15 exhaustively. 621 TestSec assertions green.
Three of round 7's fixes were half-fixes. Its $BDRIVE_HOME guard closed
the child direction only, so `bdrive init` on any ANCESTOR of the home
still pushed this device's bearer token to the hub as project content —
and a relative $BDRIVE_HOME disabled the guard entirely, reopening the
original critical. Its mail fix still seeded the pin from r.Host, so on a
fresh process an anonymous stranger could have the hub mail a VICTIM a
valid reset link on the attacker's server; the mirror image turned
password recovery off hub-wide with one request. And the hole round 7
declined to close got a reproducer that also invalidated its proposed
remedy: a peer publishes its last op unterminated, the next append fuses
onto that line and stops it decoding, so an op every teammate has on disk
vanishes with no delete op — through a byte-level PURE APPEND, which
hub-side append-only cannot stop.
Two more criticals were first contact. The loopback login callback had no
proof of possession: its only binding is a `state` that is printed to
stdout and passed to xdg-open as argv[1], so any local process that can
run `ps` signs the device in as its own account and the user's folders
sync into the attacker's project. And the three /store/* READ doors call
observeDevice as their first statement — round 5 moved it after the
decision on the write door and never touched the read doors — so one GET
with a victim's device id first-claims it hub-wide and locks that device
out of its own journal, from read permission on any single project.
The sabotage sweep is the round's most important result. 48 guards in row
15 reverted one at a time: only 20 were caught. A 57% false-negative
rate, nearly 4x rounds 6 and 7. The materialize DELETE loop's three
guards — one of which ends in os.Remove — were held up by nothing,
masked because scan's delete pass applies the same rule to the same cache
first, so no whole-Cycle fixture can tell which guard refused.
absorbLamport's ceiling and tickLamport's stop mask each other, so the
existing test passes with either removed. Eight of the 26 misses now have
tests; 18 remain open, and row 17 was never reached at all.
Also: a hostile export archive chose which of your existing projects it
landed in (create-or-join-by-name, emptiness checked after the join); a
folder that merely arrived on disk stole an enrolled mount's registry
row, so at next login the real project's daemon ran on the arriving
folder; one device approval minted N tokens and bound them to a device
the human never approved; logout left the credential live with no
revocation route; and two unbounded reads on the device side had the
declared size in scope at the call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 5 more holes found by adversarial round 9
Round 9 spent most of its budget on sabotage rather than new attacks, and
the numbers are the point. 705 TestSec assertions green.
Row 17 swept in full: 44 reversions, 9 missed (20.5%). Rows 13 and 18-22:
57 reversions, 12 missed (21% overall — row 13 at 40%, row 20 at 33%,
row 18 at 0%, the only perfect row swept in any round). And row 15's 18
remaining untested guards got tests, each verified red under its own
reversion — including both of round 8's flagged leads, which turned out
to be correct guards that were merely untested. That agent reported dry.
So roughly 29 previously-deletable guards are now genuinely pinned.
All five live holes are regressions in round 8's own fixes. Its
re-assertion fix re-published a withdrawn op at its original low lamport,
which made it a losing local unpushed op the instant it was written — so
conflictCopies did what it exists to do and the victim created, signed
and pushed a file holding content the peer chose, at a path that never
existed. The same admission rule had no guard for withdrawn deletes and
consulted neither the ignore filter nor neverSync, so a device
republished paths it deliberately refuses to materialize. Re-assertion
now requires that this folder's own cache stands behind the op.
Its sizeBound fix returned on a sha mismatch instead of skipping, so one
understated Op.Size in one line of a peer's journal permanently withheld
every blob queued behind it — before round 8 the read was unbounded, the
sha matched, and the files arrived. The bound was not the defect and is
unchanged; the error is now remembered and returned after the batch.
Also: the org heir was chosen by the smallest email address rather than
the longest-standing member, so the newest member inherited ownership and
project-admin on every project when a hub admin removed a departed
employee; $BDRIVE_HOME was created 0755 by LoadDevice, which runs before
almost everything, so listing alone named every project, every device in
the fleet and every content hash without opening one of the 0600 files;
and round 8's ResolveMount condition stranded a genuinely moved project
behind a leftover config, with init itself blocked on the same check.
Move-vs-copy is now decided by dev+ino, which a rename preserves and a
copy cannot reproduce.
Judgement call recorded: the nested-mount carry reverts green but stays.
Deleting a defence-in-depth guard because the tests did not notice is
exactly the reasoning the sabotage table exists to distrust. It is marked
as not counting toward coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 30 more holes found by adversarial round 10
Round 10 drove three surfaces end to end instead of sweeping, and the
contrast is the round's real result. Round 9 spent itself on sabotage and
found 5 holes; round 10 pointed a device at a hostile hub, executed the
Linux autostart code for the first time in ten rounds, and requested the
three /auth pages nobody had ever fetched — 30 holes. 777 TestSec
assertions green.
Row 19 is the clearest data point the loop has produced. A round-9 sweep
scored it 12.5% missed and annotated it "no reachable impact", so it
wrote zero tests. Driven end to end it held 11 holes, one critical: pull
skips a listed journal only on an exact string compare, so a hub listing
journal/DEVA.jsonl resolves to the same file as deva.jsonl on APFS and
NTFS and overwrites the device's OWN journal — the invariant the whole
concurrency design rests on. A sweep can only find a hole where a guard
exists; it cannot find a class nobody wrote a guard for, and every
critical since round 7 has been that kind.
Also from the hostile hub: one unusable listed key hid every peer
permanently; one listing minted 200k local journal files; the hub sized
the device's own allocation at two layers, and one boolean in a sign
response made a device publish an op for content it never sent, with the
cursor advanced past it so it never retried. putDirect shipped file
bytes to any host the hub named — round 4 dismissed this because "the hub
already holds the data", but at the moment it names the destination it
does not, which is what the upload is for.
Row 5's device binding is closed after four rounds of deferral. A
read-only member's device could never register, so any member with write
anywhere took its id permanently — and the arm that let them through read
a field the attacker writes. The id is now minted hub-side at login,
bound to the authenticated account, at all three mint points. That was
first framed as requiring the supersession of round 7's test; it did not.
Round 7 asserts a read door creates nothing, and that property is
unchanged and strictly stronger, because the read door now has nothing
left to claim with.
Four of round 9's five fixes had live residuals: re-assertion laundering
returned on any device that cannot push (conflictCopies measures unpushed
against a cursor that only advances on success, and read-only is the
documented steady state); the sizeBound fix still let one peer integer
suppress the victim's own push; earliestMember was inert on every
upgraded hub; and the dev+ino discriminator was inert on every row that
existed. ResolveMount turned out to be a write with a read-shaped name,
so bdrive restore and forget enrolled the device in projects it was never
init'ed into.
Recorded as a measurement gap, not a finding: row 14 was scored clean on
every backend for seven rounds, but this is the first round ever run with
a Postgres DSN, and metaBackends silently omits the arm without one. A
skipped arm and a missing guard are indistinguishable in a green suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 24 more holes found by adversarial round 11
Two criticals, both first-contact findings on surfaces ten rounds had not
reached.
An `.xml` file got script on the hub's own origin with the reader's session:
`sandboxInline` walled off a LIST of content types where the thing it protects
is a PROPERTY ("the browser parses this as a document"), and the whole XML
family sat outside the list while having the property — an XML document carries
its own `<?xml-stylesheet type="text/xsl"?>` and the XSLT output is HTML in the
origin that served it. `inlineMarkup` is now the property, `inlineType` serves
the XML family inline as text/plain so nothing parses it as a document, and
`nosniff` goes on every stored-bytes door.
A plain member replaced another account's journal by spelling her device id in
a different case: every hub ownership decision was a byte compare while APFS
and NTFS fold, so one login and one PUT broke the one-writer invariant the
whole concurrency design rests on. `canonDeviceID` folds at the trust boundary,
the registry folds on load and at every entry point, and `ownJournal` requires
the canonical journal key.
Also: org ownership was drawn by Go map iteration (`sort.Slice` on an all-zero
`Created` column) and now needs real evidence of age or produces no heir; a
revoked grant was restored by any unrelated write from a second hub process
(grant writes are row-scoped now, on all three backends); `/history` named
whoever the pushing device typed; the admin recovery arm locked the real owner
out of `bdrive login` forever, across the org wall; `bdrive scope` was the
unescaped door `forget` used to be and could wipe the team's synced rules;
`journal.SafePath` let every bidi control and every C1 through.
Two tests were touched, both disclosed in .claude/security-goal.md:
TestSec_DB_NULBytesDoNotTruncateRecords is retired (its assertion is one
Postgres cannot implement), and TestSec_Scope_AddCannotCreateADirectoryOutside-
TheProject is rewritten against the guard it was meant to test — it called
os.MkdirAll itself, so no production code sat between its setup and its
assertion. The rewrite was verified to go red with the guard removed.
918 TestSec assertions green, whole suite green including Postgres and -race,
108/108 Playwright, Linux container clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): the run-mode note only printed when the suite was already red
Round 10 moved the "postgres UNTESTED in this run" note from t.Log to
os.Stderr because t.Log is invisible without -v. That was not enough: `go
test` buffers a package's output and discards it on success without -v,
stderr included. So the note that exists to make a silent coverage gap loud
was itself audible only during a failure — the same shape as the hole it
guards against, and the reason it went unnoticed is that every round that
read it had a red suite in front of it.
secrunNotify now also writes to /dev/tty, which survives that buffering.
Verified under a pty on a fully passing run with no -v.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 24 more holes found by adversarial round 12
The critical one is the fifth instance of "something survives offboarding":
an org invite outlived the membership, the ownership AND the account that
minted it. OrgDB.Redeem/ValidInvite now resolve the minter's ownership at
read time — the rule shareCreatorStillBelongs already applied to a share
link — and retire the invite when it fails, so EvictMember's heir promotion
cannot revive it.
Round 11's row-scoped write landed on ProjectRepo only. OrgRepo, ShareRepo
and DeviceRepo had the same whole-record shape, so a second hub process's
unrelated write resurrected a revoked org membership, a revoked /s/ link,
or erased a device binding. And the authorization READ path never got it at
all: ProjectDB answered from a copy taken at boot, so a revocation took
effect on one process and no other.
Also: a push could credit another account through Op.Author; journalOps
checked the Note and not Author/UserName; a display name skipped trimText;
a password reset left outstanding reset and verification mail grants alive;
nosniff missed two stored-bytes doors; SafeText admitted the zero-width
formats; a project name could break out of the ConnectGuide paste prompt;
inviteTokenFromNext matched "/join/" anywhere in `next`; file content chose
what read-log reported as a read, and the hub recorded reads for paths that
do not exist.
Frontend: decodePath threw URIError on a link in a teammate's document and
unmounted the whole SPA persistently (fixed at the decode, plus a real
ErrorBoundary), and the router kept the Object.prototype lookup bug round 11
fixed in ProjectIcon.
Decision, not a patch: agent HOOK config (.claude/settings.json and
friends) is now reserved in both directions. Skills, commands and CLAUDE.md
deliberately are not — sharing what an agent reads is the product. The trust
boundary that follows from that is now written down in INSTALL_FOR_AGENTS.md,
the docs' Start-here path, and README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 17 more holes found by adversarial round 13
Round 12 gave ProjectDB a read-path refresh() because a revocation only took
effect on the process that served it. Four sibling registries with the
identical defect went unexamined for a round.
The refresh family (one fix, three service structs + two file repos):
- OrgDB.refresh — the wall IN FRONT of project permissions. A removed org
member kept reading every project in the org; a revoked invite still
redeemed and, on the default invite-only posture, bootstrapped the account.
At the top of the MUTATORS too: the last-owner guard is a cross-process
TOCTOU the write-side re-read cannot close.
- BuiltinAuth.refresh — the CREDENTIAL, not a grant on top of one. A revoked
device token still authenticated; a deleted account signed in again with its
old password.
- ShareDB.refresh — a revoked /s/<token> was still served to anonymous
strangers. fileShareRepo.reload's own r12 comment named this row.
- fileAccountRepo.reload, fileReadRepo.reload — the last two file repos with
no write-side re-read.
It lives in the service structs, not db_file.go: the staleness reproduces on
sqlite and Postgres too, and a file-only fix would have left the
two-replicas-one-database deployment fully broken.
Also:
- offboard now releases the device binding (DeviceRepo.Delete +
DeviceRegistry.Release). A deleted account kept a hub-wide claim on its
device id, which silently and permanently locked out the next hire.
- SafeText and trimText refuse unicode.Cf and the tag block AS A CLASS. The
tag block encodes all printable ASCII with no glyph, so a project name
rendering as "wiki" smuggled a shell command into the agent paste prompt.
- .mcp.json is reserved; the agent-config list is now derived from what each
platform LOADS, not from what BearDrive writes.
- pageDevice and X-Bdrive-Device-Name go through trimText: an unauthenticated
stranger chose the text AND the length of the hub's only consent surface.
- SetPolicy runs the startup validator, so POST /api/admin/policy cannot reach
a posture the binary refuses to boot in.
- Insights uses Object.create(null): a folder named __proto__ erased an agent
device from the Dashboard.
go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES;
-race clean; Playwright 127/127. Linux container run not completed — see
.claude/security-goal.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close the agent-onboarding holes found by round 13's fourth hacker
Four findings, all against the already-hardened tree. Its fifth (.mcp.json)
was already closed and its test passes — an independent confirmation of that
fix from a second angle.
1. A peer's `!.env` in the shared .bdriveignore uploaded another member's local
.env on their next cycle. Round 4 made .bdriveignore team-wide on purpose
and made `sync --prune` refuse on `!` rules for exactly that reason — but
that reasoning covered DELETION, and nobody asked what a pulled negation
does to SCAN. The runbook's own `bdrive init . --only docs,notes` is what
creates the exposure: the whole repo goes under the mount with only this
synced, teammate-writable file holding the rest back.
Fixed asymmetrically at the upload door (Filter.SkipUp, consulted by
walkFolder): pulled rules that NARROW apply immediately in both directions;
pulled rules that WIDEN apply to materialize but not to scan, until this
device authors the rules itself (init --only, bdrive scope, an editor). A
joining device has authored nothing, so team-wide scope still works on day
one — which a blanket "ignore pulled negations" would have broken.
`bdrive scope --explain` reads the same floor so it cannot drift.
2. A FAILED `init --server <url>` signed the device out of its real hub and
left it defaulting to the new one — after a run that ended in "Error:".
ensureLogin now returns a rollback; initCmd commits the session only once
the hub has answered with a project this device can open.
3. `init --server http://…` minted and stored a device token with no plaintext
warning while `bdrive login` on the same URL warned — and step 2 of the
runbook is titled "Do not run a login command". The warning moved from
loginCmd's RunE into the shared runLogin: one sign-in door, one warning.
4. The hub chose the device-login link and the CLI printed it verbatim under
its own "open this link in any browser". sameOriginLink falls back to the
hub's own /auth/device when scheme+host differ.
Also: safeField gets the same unicode.Cf + tag-block class rule SafeText and
trimText got — third door, same class. Scoping guide documents the widening
rule. Two findings that are not tests (the runbook URL pinned to a mutable
branch; nothing authenticates the hub during device sign-in) are recorded in
known-open.
go build / go vet / go test ./... clean with and without BDRIVE_TEST_POSTGRES;
-race clean on webapp, syncer, store, cmd/bdrive; Playwright 127/127. Linux
container run still not completed — the Docker daemon on this machine will not
create containers at all; see .claude/security-goal.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(security): close 22 more holes found by adversarial round 14
The last round of the loop. Four hackers landed; 21 of their tests go green
here, the 22nd names a hole that IS fixed but cannot pass as written (see
below). `.claude/security-goal.md` gains a handover section for a human in
place of the "next round's targets" framing.
Sync / scope (internal/syncer)
- A peer DELETING the shared `.bdriveignore` walked straight past round 13's
upload floor: the `IgnorePulled` bookkeeping sat behind `if want, ok :=
target[IgnoreFile]; ok`, so a delete never updated it while materialize
unlinked the local copy anyway — and the next cycle read the absent file as
locally authored, dropped the floor AND the live rules, and pushed the whole
repo. Recorded as pulled now.
- Both SyncState fields are `omitempty`, so on its first post-upgrade cycle
every existing device adopted whatever was on disk as its own — including a
peer's `!.env` that landed one cycle earlier (scan runs before pull, so it
always does). An upgraded device now seeds its floor with `vouchedFloor`:
keep a `!` line when the path it re-includes is already in this mount's
materialization cache, drop it otherwise. That is what keeps a `bdrive
scope` block (`/*` plus nothing but negations) from silently ending uploads
— pinned by TestUpgradedScopedDeviceKeepsUploading.
Second-process staleness (internal/webapp)
- ProjectDB had refresh() on Get and List only. `put`→`PutMeta` is an
unconditional upsert, so a second process's ordinary rename put a DELETED
project back carrying the org the public-link rule reads; the last-ADMIN
guards counted admins out of the boot-time map; GetOrCreate answered
create-or-join differently per replica. refresh() now runs at the top of all
ten mutators.
- DeviceRegistry had no refresh() at all — round 13 cleared it on the
bind-away direction alone. Offboarding released a device claim on one
process and no other: the next hire is locked out, and a re-created address
inherits the departed account's journal write gate elsewhere. Pinned on
file, sqlite and postgres.
Audit trail
- `/store/object` was a plain object PUT with no relation to what is stored,
so any member could rewind their own journal — or, after inheriting a
reassigned device id, a departed member's — out of History. Journal pushes
now must keep every op Seq the hub already holds.
- seedTemplate journaled the hub's own template files under the account that
ran `bdrive init --template`, byte-identical in shape to a hand upload. They
now carry no account and a "seeded from the <name> template" note.
Text and rendering
- journal.SafeText refused every category-Cf rune and missed U+2028/U+2029
(Zl/Zp), which the webapp's own trimText has deleted by number since round
12. A folder row for `line<U+2028>sep.md` paints to exactly the same glyph
run as `line sep.md`.
- A strong-RTL LETTER needs no format character to reorder a rendered row.
Measured in Chromium, `unicode-bidi: isolate`, `plaintext` and `<bdi>` all
leave it intact; `isolate-override` fixes it, and peer-written-name
selectors now carry it (SPA + the auth pages' device-approval rows).
- `)` closes the paste prompt's clause exactly as `"` did; project names now
drop both parens (org/device/account names are unaffected). PATCH
/api/projects/{id} called trimText where create called trimName, so rename
stored `/` and `\` — one rule for both doors now.
CLI
- `p.Template` was the one hub-chosen field in `bdrive init`'s output that
never reached safeField.
- `--template` reported the hub's own string as proof and never looked at what
arrived; it now always falls through to the idempotent seedLocally.
Docs
- INSTALL_FOR_AGENTS.md no longer raises the hub-seeded AGENTS.md to the
user's authority, and its trust boundary names the hub as an author of
folder content. Documentation defects, not demonstrated exploits: three live
headless runs did not flip behaviour.
Known red, deliberately not worked around:
- TestSec_ProjectName_RenameBypassesTheCreateNameRule — the hole is fixed and
separately verified; the test's own control creates a project with the
normalized name in the same org before renaming into it, so correct
behaviour collides with the unique-name-per-org rule.
- Two e2e/sec14fe specs upload a U+2028 path to demonstrate a rendering
collision that this commit's ingest fix now refuses at the door.
go build / go vet clean. go test ./... green with and without
BDRIVE_TEST_POSTGRES and under -race, except the test named above. Playwright
serial: 131 passed, 2 failed (the two named above).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(security): the rename test collided with its own control
TestSec_ProjectName_RenameBypassesTheCreateNameRule created a project
from the same payload it then renamed a second project to. Both
normalize to "notes....etc", names are unique per org, so the rename
400'd on the collision and the test failed at its control check without
ever reaching the assertion it exists to make.
It failed that way against the FIXED code, which is the worst way for a
test to be wrong: it reads as an open hole and is really a broken
instrument. The control now uses a payload that normalizes to a
different name.
Verified load-bearing rather than merely green: reverting Update's
projectLabel call turns it red with "rename stored a path separator in
a project name: notes/../../etc".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs(architecture): draw the security hardening's new types and seams
The branch added shared rules and gates that the diagrams did not have
boxes for, so the pictures no longer described the code.
cli-sync: the three drifting path/text checks collapse into one
journal.SafePath/SafeText box, plus store.UnderRoot and
config.ReservedPath as their own single-rule boxes; Filter gains SkipUp
and AcceptRules and the scan/materialize rules stop being symmetric;
SyncState is drawn for the IgnoreAccepted/IgnorePulled floor; the
registry records Dev/Ino and splits ResolveMount (read, self-heal) from
EnrollMount (the only writer); the daemon's signalled pid moved inside
the flock.
webapp-server: DeviceRegistry is keyed (account, id) with
Bind/Release/OwnerOf/MayActAs; new boxes for the /store journal door,
the quota reservation ledger, sandboxInline and offboard; refresh() on
every service; the row-scoped repo interfaces, the storable validation
gate and the schema-version guard in the MetaStore block; HasBlob became
BlobSize.
webapp-frontend: ErrorBoundary, the app's floor. Also fixes escaped
quotes in a note that have been rendering this whole diagram as an error
box on GitHub since it was written.
overview: unchanged — no package appeared or disappeared and no
cross-piece flow moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(security): re-aim two specs their own fix made unbuildable
Round 14 refused U+2028/U+2029 at ingest. Two e2e specs proved a
*rendering* collision by uploading exactly those code points, so the fix
made their fixture impossible and left them red — a broken instrument
reading as an open hole, the same shape as the rename test.
The listing spec now asserts the ingest guard instead, and keeps the
measurement that says why the guard matters: the two names painted to
70.9844 x 16, one line box, byte-different and pixel-identical, measured
in Chromium with Range.getClientRects() over live text nodes. Relax
SafeText and it goes red before the collision returns.
The shares-audit spec is skipped with its numbers preserved. Its
reachable sibling — a strong-RTL letter, which cannot be refused without
refusing Hebrew filenames — is covered by the neighbouring spec and
fixed in style.css.
Playwright: 133 passed, 1 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(security): a comment in a spec leaked a CSS rule into the bundle
Tailwind's content scanner reads the e2e specs. A bare `isolate` token in
prose explaining the bidi fix emitted `.isolate{isolation:isolate}` into
the shipped bundle — a rule nothing uses, and a stale-assets failure for
check-dist.
Reworded to avoid the token, with a note saying why, since the next
person to explain a utility class in a comment will hit the same thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs(security): correct three handover entries the landing pass closed
The handover listed one Go test and two browser specs as permanently red.
All three were re-aimed after the loop stopped: the Go test was failing
against the FIXED code because its own control collided with it, and the
two specs were fixture-blocked by round 14's own ingest fix.
Suite is 1052 TestSec assertions green, 0 red; Playwright 133 passed,
1 skipped. The stopping condition itself is still not met and the notice
at the top of the file says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(sync): a Persian filename permanently wedged a device's sync
The scan door applied config.ReservedPath; the hub's ingest door applied
journal.SafePath AND config.ReservedPath. A file whose name the scan
accepted and the hub refused was blobbed and journaled locally, and
because push PUTs the whole journal object, every later push from that
device 400'd on the same op forever. Renaming did not help — the delete
op names the same path. The only surface was a line in daemon.log, and
recovery meant deleting the volume store.
walk.go's own comment already stated the rule it was breaking: "the
outbound half has to match the inbound one." It just did not name every
predicate the inbound half applies.
Two changes:
SafePath now permits ZWNJ and ZWJ, which the Cf class rule refused. Both
are orthographically required — U+200C is what makes "میروم" the right
word in Persian and is mandatory in several Indic scripts, and U+200D
builds most multi-person emoji. Refusing them did not harden a hub; it
told those users their filenames were illegal. The confusability they
buy is also already reachable without them: a Cyrillic homoglyph
produces the identical "two rows, one reader" tree and is allowed. So
the clause was paying a hard i18n cost for a partial mitigation of a
class that stays open. A note has no orthography, so SafeText still
refuses all four zero-widths; the two rules now share one implementation
with a flag rather than diverging.
walkFolder applies SafePath, so an unsyncable name is simply not carried
— it shows up in `bdrive scope --explain` like any other exclusion and
never enters a journal.
The regression test asserts a PROPERTY over both predicates rather than a
character list, so adding a rule to one door alone reopens it. Its first
draft was vacuous — it skipped every unsafe name instead of creating it,
so nothing hostile reached the scan and deleting the guard changed
nothing. Caught by sabotaging it; it now creates the names unix permits
and goes red when the guard is removed.
Found by a CTO review of PR #112, not by the security rounds, which
checked this asymmetry on the hub and never on the client.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(hub): a proxied hub throttled all its users as one on day one
Upgrading a hub behind nginx / Caddy / Fly / Cloud Run without editing its
config made every user share ONE 10/min login bucket and capped public share
links hub-wide at 120/min: clientIP fell back to r.RemoteAddr, which is the
proxy. Correct passwords started answering "too many attempts", with no log
line saying why.
X-Forwarded-For is now trusted by PEER rather than by configuration: a proxy
that fronts a hub reaches it over loopback or a private address (sidecar,
container network, Fly/Cloud Run internal hop), so that header is the
operator's own infrastructure. A hub on a public IP still ignores it, and now
logs once instead of failing silently. trust_proxy remains the override for
the one shape the peer check cannot see — a proxy on a public address.
Which hop is taken is unchanged (last element of the last field line), and the
round 13/14 tests that pin it stay green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* perf(hub): authorization stopped re-reading the whole registry per request
Every authorized request re-read the entire project, org and account registry
under a hub-wide mutex — 14.1 ms per request at 5k projects on the file
backend, nine unfiltered SELECTs on Postgres — so the hub served roughly 60
req/s regardless of cores while ~200 devices polled /store/list every 10s.
None of this existed before the security rounds; registries loaded once at
boot.
The semantics do not change. Registries still re-read the store before every
authorization decision — that is the correctness floor rounds 12-14 built, and
a TTL would put back exactly the staleness window they closed. What changes is
the cost of asking whether there is anything to re-read:
- new optional Versioned repo capability: one os.Stat (file) or one
primary-key lookup on a per-registry meta_version counter bumped inside
every write transaction (SQL). A repo that cannot answer is treated as
changed, so the fallback is the unconditional re-read that was always there.
- proj() resolved the project and then projectPerm resolved it again;
projectPermOf takes the Project the choke point already has. handleProjectList
and the org share audit did one resolution PER PROJECT in a loop; both now
pass the row they are already holding.
Measured on M1, benchtime=200x, one project resolve + permission check:
file 100 projects 331 us -> 6.0 us
file 1000 projects 2.85 ms -> 3.9 us
file 5000 projects 14.14 ms -> 3.9 us
sqlite 100 projects 295 us -> 22.9 us
sqlite 1000 projects 2.57 ms -> 22.5 us
sqlite 5000 projects 10.86 ms -> 21.5 us
and it is now flat in project count rather than linear.
The file backend does NOT become multi-process-safe from this: every write is
still read-modify-write-rename, and the mtime+size token would miss two
processes writing the same byte count within one filesystem timestamp tick.
refresh narrows the stale-read race; it does not close it. SQL is the fix.
TestVersionGateSeesAnotherProcessWrite pins the property that matters — a
second process's create and grant change are both visible through the gate, on
file, sqlite and Postgres. BenchmarkRegistryRead / BenchmarkAuthorizedRequest
go linear again if this regresses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* perf(hub): every blob read on S3/GCS paid double egress and a full hash first
RemoteSource.verify re-read and re-hashed the whole object before returning a
second read for the actual stream, on any backend that can presign — and
PutSigner survives the Prefixed wrapper, so this was live on every S3 and GCS
hub. Every viewer open, render, download and /s/* share hit paid 2x
object-store egress and a serialized full-object hash before the user's first
byte. On file:// (the OSS default) verify is a no-op, which is why the suite
never felt it.
The check is not weakened. It is cached again, keyed on the one thing that
makes "blobs are immutable" true rather than assumed: BOTH presign doors
refuse to sign a key that already exists, so every presigned URL a blob ever
gets was minted before its first PUT and dies at mint+TTL. Once the stored
object is older than the presign TTL, no live URL for it can exist and none
will ever be minted again — the hub is the only writer left, and the hub hashes
what it relays. Only then is the verification cached. The object's age is read
after the hash, so a replay mid-check reads as seconds old and is not sealed.
Measured, 4 MiB blob, 200 reads (the "unsealed" arm IS the old code path):
before 2.41 ms/op 2.000 storage reads per blob read
after 0.39 ms/op 1.000 storage reads per blob read
On S3/GCS the second read is real egress and real latency, so the win is
larger there than this local stand-in shows.
NOT done: signing the content hash into the presigned URL. GCS cannot bind a
SHA-256 at all — x-goog-hash takes only crc32c and md5, and the md5 would be
declared by the same client that declares the sha, so a chosen-prefix collision
defeats it. On S3 the SDK hoists ChecksumSHA256 into the query string rather
than into SignedHeader; it is inside the signature, but whether S3 enforces a
hoisted checksum (and whether an unsigned request header would override it)
cannot be verified without a real bucket. With the seal in place the checksum
would add no security that verify is not already providing during the only
window it applies to, so it stays out rather than going in untested.
Backends that can sign but cannot bind a content address: GCS certainly, S3
pending a live check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs: record the three PR #112 cost fixes and what they do not fix
architecture/webapp-server.md gains Versioned/versionGate, RemoteSource's
PresignTTL + seal, remote.Object.Modified, and the new clientIP peer rule.
Both mermaid blocks parse-checked with mmdc.
.claude/security-goal.md's "known-open, deliberately deferred" list is updated
with what each fix accepts: the private-peer widening on X-Forwarded-For, the
fact that the file backend does NOT become multi-process-safe from the change
token (read-modify-write-rename is unchanged; mtime+size narrows the race and
does not close it), and why the presigned content hash was left out — GCS
cannot bind a SHA-256 at all, and S3's binding lands as a hoisted query
parameter nothing here can verify S3 enforces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* fix(hub): the blob seal compared two clocks that need not agree
The verification cache is sound on one argument: once a stored blob is
older than the presign TTL, no live URL for it can exist, so the bytes
cannot change again and the hash need not be recomputed.
That argument is about time, and the two times came from different
machines — o.Modified is the object store's clock, time.Since is the
hub's. A hub running ahead of storage overstates the object's age and
seals it while a minted URL is still live; a replay through that URL is
then served from cache for the rest of the process's life. NTP makes it
unlikely and a container without it, or a VM resumed from suspend, makes
it reachable.
Seal after the TTL plus an hour instead. Sealing early buys nothing —
the blob is immutable either way — so the margin costs a few extra
hashes on a young blob and removes a dependency on two clocks agreeing
that nothing in the process can verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(hub): age the blob-seal fixtures instead of shrinking the TTL
The clock-skew fix in b1e9d20 is right, and it left TestBlobVerification
StopsOnceTheObjectCannotChange red: the fixture used a 1ns presign TTL as a
stand-in for "old enough to seal", which stops working the moment the margin
is an absolute allowance for clock skew rather than a multiple of the TTL —
correctly so, since an absolute allowance is what skew actually needs.
The fixtures now age what the store holds (os.Chtimes over the backing dir),
so the tests exercise a genuinely old object. That also lets the boundary the
skew fix exists for be asserted directly: a blob PAST the presign TTL but
inside the skew allowance must still be re-verified, because "past the TTL" is
measured on the storage clock and compared on the hub's. Previously nothing
covered that case.
sealAfter's comment now names what the allowance does not buy — it is a bound,
not a proof — with the single-clock alternative as the upgrade path, and the
goal file records the same residual.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* test(hub): a partial Postgres reset was a half-applied migration
metaBackends' postgres reset dropped ten tables and kept four: project_perms
and device_rows leaked rows into the next test, and — the one that bites —
schema_meta survived while projects did not. That combination is precisely
what addColumns refuses: the next open rebuilds projects WITHOUT the guarded
default_level column and then reads a recorded schema version saying it should
already be there, so the store fails to open with the rollback error.
Nothing tripped it while TestMetaStoreConformance was the only thing opening
Postgres, because it reset and opened back to back. Adding a second Postgres
consumer (TestVersionGateSeesAnotherProcessWrite) made the residue reachable.
Reset now drops every table migrate() creates, which is what reset means. And
the version-gate test no longer drops anything at all: it asserts by project
id and GetOrCreate is create-or-join, so it does not need a clean database and
must not leave a residue the other harness does not expect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
* docs: a red Postgres result is only evidence when nothing else shares the DSN
The harness DROPs and recreates the schema per test against whatever
BDRIVE_TEST_POSTGRES names, so two concurrent runs produce moving failures
that read as regressions. Record how to tell the two apart, since the
scoreboard leans on Postgres results.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1263 lines
53 KiB
Go
1263 lines
53 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Round 14: the cells of the offboarding matrix round 13 left open, plus the
|
|
// columns that had no column at all — org rename, "the org is gone", project
|
|
// delete beyond its share links, and device re-registration.
|
|
//
|
|
// Same method as round 13: a POSITIVE CONTROL that proves the capability was
|
|
// real, then the revocation route, then the same request again. All helpers
|
|
// here are prefixed sec14m; the fixtures (sec13mHub, sec13mServer, permHub,
|
|
// doAs, signupAndSession) are reused, never copied.
|
|
|
|
// ---- helpers -------------------------------------------------------------
|
|
|
|
// sec14mBindReq is the request finishLogin hands bindDevice: a machine naming
|
|
// itself while its account is being authenticated.
|
|
func sec14mBindReq(id, name string) *http.Request {
|
|
r := httptest.NewRequest("POST", "/auth/cli", nil)
|
|
r.Header.Set("X-Bdrive-Device", id)
|
|
r.Header.Set("X-Bdrive-Device-Name", name)
|
|
return r
|
|
}
|
|
|
|
// sec14mDeleteAccount drives the hub's ONE account-removal path (Deny →
|
|
// Server.offboard), which is what `bdrive` offboarding actually is.
|
|
func sec14mDeleteAccount(t *testing.T, srv *Server, email string) {
|
|
t.Helper()
|
|
a := srv.Auth.(*BuiltinAuth)
|
|
a.mu.Lock()
|
|
u := a.findByEmail(email)
|
|
a.mu.Unlock()
|
|
if u == nil {
|
|
t.Fatalf("sec14mDeleteAccount: no account %s", email)
|
|
}
|
|
if err := a.Deny(u.ID); err != nil {
|
|
t.Fatalf("sec14mDeleteAccount %s: %v", email, err)
|
|
}
|
|
}
|
|
|
|
// sec14mLogin signs an EXISTING account in through the real login page — what
|
|
// a second hub process gives a browser whose account was created on the first.
|
|
func sec14mLogin(t *testing.T, h http.Handler, email, pass string) *http.Cookie {
|
|
t.Helper()
|
|
form := url.Values{"email": {email}, "password": {pass}}
|
|
req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := doHTTP(h, req)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("sec14mLogin %s: %d %s", email, rec.Code, rec.Body)
|
|
}
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == sessionCookie {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("sec14mLogin %s: no session cookie", email)
|
|
return nil
|
|
}
|
|
|
|
// sec14mStorePut writes a store key as one signed-in browser session naming a
|
|
// device — the shape of every /store/* write.
|
|
func sec14mStorePut(t *testing.T, h http.Handler, project, key, device string, body []byte, c *http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptestNewRequestBody("PUT", "/api/p/"+project+"/store/object?key="+key, body)
|
|
req.Header.Set("X-Bdrive-Device", device)
|
|
req.AddCookie(c)
|
|
return doHTTP(h, req)
|
|
}
|
|
|
|
// ---- cell: device binding x account deletion x SECOND HUB PROCESS --------
|
|
|
|
// Round 13 closed "a device claim must not outlive its account"
|
|
// (TestSec_Matrix_AccountDeletionReleasesTheDeviceBinding) inside ONE process.
|
|
// DeviceRegistry was then recorded as "verified-not-applicable" for the
|
|
// second-process class on the strength of the BIND-AWAY direction alone
|
|
// (TestSec_Devices_ASecondHubProcessCannotBindAwayAnExistingDeviceID). The
|
|
// RELEASE direction was never driven, and DeviceRegistry has no refresh() at
|
|
// all: byKey is loaded at open and only ever mutated by this process.
|
|
//
|
|
// So on a hub running two processes in front of one devices.json — the
|
|
// deployment the SQL backend exists for — offboarding an account releases its
|
|
// device claims on whichever process served the deletion and on no other. Both
|
|
// halves of Release's own doc comment come back:
|
|
//
|
|
// - the next hire is silently locked out: the departed owner is invisible to
|
|
// her (no shared org any more), so Bind's invisible-conflict arm binds
|
|
// NOTHING and lets the login succeed, and every push then 403s telling her
|
|
// to run `bdrive login`, which is what she just did;
|
|
// - and the mirror, which is the authorization direction: re-create the
|
|
// address and the new account inherits the departed employee's device row
|
|
// and write access to that device's journal.
|
|
func TestSec_Matrix_ADeviceReleaseIsHonouredByEveryHubProcess(t *testing.T) {
|
|
_, srvA, dir, root, _, _ := sec13mHub(t)
|
|
const id = "devlaptop14"
|
|
|
|
// Bob's machine signs in on process A: the id becomes his, hub-wide.
|
|
if err := srvA.bindDevice("bob@x.io", sec14mBindReq(id, "the-laptop")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if owner, _ := srvA.Devices.OwnerOf(id); owner != "bob@x.io" {
|
|
t.Fatalf("fixture: OwnerOf = %q", owner)
|
|
}
|
|
|
|
// A second hub process, up now, in front of the same metadata.
|
|
srvB := sec13mServer(t, dir, root)
|
|
_ = srvB.Handler()
|
|
|
|
// Positive control: while bob's account lives, process B holds the same
|
|
// claim and refuses to hand the id to carol. That refusal is correct, and
|
|
// it proves B is really reading this row.
|
|
if err := srvB.bindDevice("carol@x.io", sec14mBindReq(id, "carol-pc")); err == nil {
|
|
t.Fatal("fixture: process B should refuse carol a live account's device id")
|
|
}
|
|
|
|
// The real revocation route: bob's ACCOUNT is deleted on process A.
|
|
sec14mDeleteAccount(t, srvA, "bob@x.io")
|
|
if owner, _ := srvA.Devices.OwnerOf(id); owner != "" {
|
|
t.Fatalf("fixture: process A should have released the claim, got %q", owner)
|
|
}
|
|
|
|
if owner, _ := srvB.Devices.OwnerOf(id); owner != "" {
|
|
t.Errorf("process B still says %q owns the reassigned laptop after that account was "+
|
|
"DELETED on process A: DeviceRegistry has no read-path refresh, so Release only ever "+
|
|
"took effect on the process that served the offboarding.", owner)
|
|
}
|
|
// The lockout half: the next hire's login must actually take the id.
|
|
if err := srvB.bindDevice("carol@x.io", sec14mBindReq(id, "carol-pc")); err != nil {
|
|
t.Fatalf("process B refused the next hire the reassigned laptop: %v", err)
|
|
}
|
|
if owner, _ := srvB.Devices.OwnerOf(id); owner != "carol@x.io" {
|
|
t.Errorf("after a successful bind on process B, OwnerOf = %q, want carol@x.io — "+
|
|
"a login that binds nothing hands back a token whose every push 403s, forever", owner)
|
|
}
|
|
}
|
|
|
|
// The mirror of the same stale map, and this one is authorization, not
|
|
// availability: the address is signed up again (an owner re-invites a
|
|
// contractor, a re-hire), and on the second hub process the NEW account walks
|
|
// straight into the departed one's device row — which is the WRITE gate for
|
|
// that device's journal on every project on the hub.
|
|
func TestSec_Matrix_ARecreatedAddressDoesNotInheritAReleasedDeviceOnAnyProcess(t *testing.T) {
|
|
hA, srvA, dir, root, _, _ := sec13mHub(t)
|
|
const id = "devlaptop14b"
|
|
|
|
if err := srvA.bindDevice("bob@x.io", sec14mBindReq(id, "the-laptop")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srvB := sec13mServer(t, dir, root)
|
|
hB := srvB.Handler()
|
|
if owner, _ := srvB.Devices.OwnerOf(id); owner != "bob@x.io" {
|
|
t.Fatalf("fixture: process B should see the claim, got %q", owner)
|
|
}
|
|
|
|
sec14mDeleteAccount(t, srvA, "bob@x.io")
|
|
|
|
// The address is signed up again. Same string, different account.
|
|
if c := signupAndSession(t, hA, "bob@x.io", "Bob Two", "password2"); c == nil {
|
|
t.Fatal("fixture: re-signup failed")
|
|
}
|
|
if owner, _ := srvA.Devices.OwnerOf(id); owner != "" {
|
|
t.Fatalf("fixture: process A should hold no claim, got %q", owner)
|
|
}
|
|
_ = hB
|
|
if owner, _ := srvB.Devices.OwnerOf(id); owner == "bob@x.io" {
|
|
t.Errorf("on process B a BRAND NEW account on the departed employee's address owns "+
|
|
"device %s and may rewrite its journal: the release never reached this process", id)
|
|
}
|
|
}
|
|
|
|
// ---- cell: explicit project grant x demotion, on every route -------------
|
|
|
|
// Round 13 covered the IMPLICIT project-admin an org owner carries
|
|
// (TestSec_Matrix_DemotionDropsImplicitProjectAdmin). The EXPLICIT
|
|
// Project.Perms grant — the one an admin actually edits — was never demoted
|
|
// and re-probed. Every level a route can declare is probed here, including
|
|
// ownJournal's project-admin RECOVERY arm, which is the one place PermAdmin
|
|
// buys write access to somebody else's journal object.
|
|
func TestSec_Matrix_ExplicitProjectGrantDemotionIsHonouredOnEveryRoute(t *testing.T) {
|
|
h, srv, cookies, p := permHub(t)
|
|
bob := cookies["bob"]
|
|
|
|
// Alice grants bob an explicit admin, and carol one too so the last-admin
|
|
// guard is not what refuses the demotion later.
|
|
for _, who := range []string{"bob", "carol"} {
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/"+who+"@x.io",
|
|
map[string]string{"level": PermAdmin}, cookies["alice"]); rec.Code != 200 {
|
|
t.Fatalf("fixture: grant %s admin: %d %s", who, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
adminRoutes := []struct {
|
|
name, method, url string
|
|
body any
|
|
}{
|
|
{"rename the project", "PATCH", "/api/projects/" + p.ID, map[string]string{"name": "renamed"}},
|
|
{"set the default level", "PUT", "/api/p/" + p.ID + "/permissions", map[string]string{"default": PermRead}},
|
|
{"grant a level", "PUT", "/api/p/" + p.ID + "/permissions/carol@x.io", map[string]string{"level": PermWrite}},
|
|
{"clear a grant", "DELETE", "/api/p/" + p.ID + "/permissions/carol@x.io", nil},
|
|
{"delete the project", "DELETE", "/api/projects/" + p.ID, nil},
|
|
}
|
|
writeRoutes := []struct {
|
|
name, method, url string
|
|
body any
|
|
}{
|
|
{"mint a share", "POST", "/api/p/" + p.ID + "/shares", map[string]string{"path": "wiki/a.md"}},
|
|
{"start an upload", "POST", "/api/p/" + p.ID + "/upload/init", map[string]any{"path": "x.md", "size": 1}},
|
|
{"ask for a signed put", "POST", "/api/p/" + p.ID + "/store/sign", map[string]any{"key": "blobs/" + strings.Repeat("a", 64), "size": 1}},
|
|
{"restore a version", "POST", "/api/p/" + p.ID + "/restore", map[string]string{"path": "wiki/a.md", "sha": strings.Repeat("b", 64)}},
|
|
{"remove a path", "POST", "/api/p/" + p.ID + "/remove", map[string]string{"path": "wiki/a.md"}},
|
|
}
|
|
readRoutes := []struct{ name, url string }{
|
|
{"tree", "/api/p/" + p.ID + "/tree"},
|
|
{"file", "/api/p/" + p.ID + "/file?path=wiki/a.md"},
|
|
{"download", "/api/p/" + p.ID + "/download?path=wiki/a.md"},
|
|
{"render", "/api/p/" + p.ID + "/render?path=wiki/a.md"},
|
|
{"history", "/api/p/" + p.ID + "/history"},
|
|
{"share list", "/api/p/" + p.ID + "/shares"},
|
|
{"permissions", "/api/p/" + p.ID + "/permissions"},
|
|
{"store list", "/api/p/" + p.ID + "/store/list"},
|
|
{"store exists", "/api/p/" + p.ID + "/store/exists?key=blobs/" + strings.Repeat("a", 64)},
|
|
}
|
|
|
|
// Positive control: with the explicit admin grant bob reaches every one of
|
|
// them. "delete the project" is exercised only in the demoted state — the
|
|
// control for it is the four siblings gated by the identical PermAdmin.
|
|
for _, rt := range adminRoutes[:len(adminRoutes)-1] {
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, bob); rec.Code == http.StatusForbidden {
|
|
t.Fatalf("fixture: explicit project admin bob cannot %s: %d %s", rt.name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
for _, rt := range writeRoutes {
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, bob); rec.Code == http.StatusForbidden {
|
|
t.Fatalf("fixture: explicit project admin bob cannot %s: %d %s", rt.name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
// ownJournal's recovery arm: PermAdmin is what lets a caller write a
|
|
// journal object bound to no account of theirs.
|
|
if rec := sec14mStorePut(t, h, p.ID, "journal/sec14mfree.jsonl", "sec14mfree", []byte("\n"), bob); rec.Code == http.StatusForbidden {
|
|
t.Fatalf("fixture: explicit project admin bob cannot use the journal recovery arm: %s", rec.Body)
|
|
}
|
|
|
|
// The control sweep cleared carol's grant; put it back so the demotion
|
|
// below is refused by nothing but the thing under test.
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/carol@x.io",
|
|
map[string]string{"level": PermAdmin}, cookies["alice"]); rec.Code != 200 {
|
|
t.Fatalf("fixture: restore carol's admin grant: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
// The real revocation route: alice demotes bob's explicit grant to read.
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/bob@x.io",
|
|
map[string]string{"level": PermRead}, cookies["alice"]); rec.Code != 200 {
|
|
t.Fatalf("demote bob to read: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
for _, rt := range adminRoutes {
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, bob); rec.Code != http.StatusForbidden {
|
|
t.Errorf("after demotion to read, bob can still %s: %d %s", rt.name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
for _, rt := range writeRoutes {
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, bob); rec.Code != http.StatusForbidden {
|
|
t.Errorf("after demotion to read, bob can still %s: %d %s", rt.name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
if rec := sec14mStorePut(t, h, p.ID, "journal/sec14mfree2.jsonl", "sec14mfree2", []byte("\n"), bob); rec.Code != http.StatusForbidden {
|
|
t.Errorf("after demotion to read, bob still writes an unowned device's journal: %d %s", rec.Code, rec.Body)
|
|
}
|
|
// Read still works — the demotion is to read, not to none.
|
|
for _, rt := range readRoutes {
|
|
if rec := doAs(t, h, "GET", rt.url, nil, bob); rec.Code == http.StatusForbidden {
|
|
t.Errorf("demotion to read also took %s away: %d %s", rt.name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// And the whole way down: read -> none must close the read routes too, and
|
|
// take the project out of the list.
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/bob@x.io",
|
|
map[string]string{"level": PermNone}, cookies["alice"]); rec.Code != 200 {
|
|
t.Fatalf("demote bob to none: %d %s", rec.Code, rec.Body)
|
|
}
|
|
for _, rt := range readRoutes {
|
|
if rec := doAs(t, h, "GET", rt.url, nil, bob); rec.Code != http.StatusForbidden {
|
|
t.Errorf("after demotion to none, bob can still read %s: %d %s", rt.name, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
if rec := doAs(t, h, "GET", "/api/projects", nil, bob); strings.Contains(rec.Body.String(), p.ID) {
|
|
t.Errorf("a project bob is denied is still listed to him: %s", rec.Body)
|
|
}
|
|
// Create-or-join by name must not hand the id back either.
|
|
if rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "renamed"}, bob); rec.Code != http.StatusForbidden {
|
|
t.Errorf("create-or-join by name hands a denied project back: %d %s", rec.Code, rec.Body)
|
|
}
|
|
_ = srv
|
|
}
|
|
|
|
// ---- cell: device binding x demotion, and x removal from the org ---------
|
|
|
|
// OwnerOf is deliberately hub-wide, so neither a demotion nor a removal from
|
|
// the org may release a device claim — releasing it would hand a departing
|
|
// member's journal to whoever is left in the org, and History would keep
|
|
// crediting her. Nobody had ever asserted either direction; "it probably
|
|
// should" is how five cells of this matrix were missed.
|
|
//
|
|
// What removal MUST do is stop the row being joined into that org's surfaces.
|
|
func TestSec_Matrix_DeviceBindingOutlivesDemotionAndOrgRemoval(t *testing.T) {
|
|
h, srv, dir, root, cookies, p := sec13mHub(t)
|
|
_, _ = dir, root
|
|
orgs := srv.Dir.(LocalDirectory).OrgDB
|
|
const id = "devbob14c"
|
|
|
|
if err := srv.bindDevice("bob@x.io", sec14mBindReq(id, "bob-laptop")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A second owner so alice can be demoted at all.
|
|
if err := orgs.SetRole(p.Org, "carol@x.io", RoleOwner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// --- demotion (owner -> member) ---
|
|
const aliceDev = "devalice14c"
|
|
if err := srv.bindDevice("alice@x.io", sec14mBindReq(aliceDev, "alice-mbp")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := doAs(t, h, "PATCH", "/api/orgs/"+p.Org+"/members/alice@x.io",
|
|
map[string]string{"role": RoleMember}, cookies["carol"]); rec.Code != 200 {
|
|
t.Fatalf("demote alice: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if owner, _ := srv.Devices.OwnerOf(aliceDev); owner != "alice@x.io" {
|
|
t.Errorf("demoting alice released her device claim (owner now %q): a claim that "+
|
|
"evaporates on a role change is a claim the rest of the org can take", owner)
|
|
}
|
|
if err := srv.bindDevice("carol@x.io", sec14mBindReq(aliceDev, "carol-pc")); err == nil {
|
|
if owner, _ := srv.Devices.OwnerOf(aliceDev); owner == "carol@x.io" {
|
|
t.Errorf("carol took a demoted member's device id, and with it her journal")
|
|
}
|
|
}
|
|
|
|
// --- removal from the org ---
|
|
if rec := doAs(t, h, "DELETE", "/api/orgs/"+p.Org+"/members/bob@x.io", nil, cookies["carol"]); rec.Code != 200 {
|
|
t.Fatalf("remove bob: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if owner, _ := srv.Devices.OwnerOf(id); owner != "bob@x.io" {
|
|
t.Errorf("removing bob from the org released his device claim (owner now %q): "+
|
|
"OwnerOf is hub-wide on purpose — offboarding a teammate must not hand her "+
|
|
"journal to the org she left", owner)
|
|
}
|
|
if err := srv.bindDevice("carol@x.io", sec14mBindReq(id, "carol-pc")); err == nil {
|
|
if owner, _ := srv.Devices.OwnerOf(id); owner == "carol@x.io" {
|
|
t.Errorf("carol took a removed member's device id: the claim did not survive removal")
|
|
}
|
|
}
|
|
// The row must stop being VISIBLE in the org bob left.
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put(id, "wiki/bob.md", "bob wrote this")
|
|
rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/history", nil, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("history: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "bob-laptop") {
|
|
t.Errorf("history still joins a removed member's device row into this org's feed: %s", rec.Body)
|
|
}
|
|
}
|
|
|
|
// ---- new column: the org is gone -----------------------------------------
|
|
|
|
// There is no DeleteOrg route — but an org CAN be emptied, and it is the one
|
|
// routine operator action that gets there: offboard the last member and
|
|
// EvictMember drops the row without promoting anyone (there is nobody left).
|
|
// Nothing had ever probed that state, and every fail-OPEN in it is a project's
|
|
// whole content handed to whoever asks: orgOf() still answers a non-empty id,
|
|
// so the "org is missing" guards (projectPerm, shareCreatorStillBelongs,
|
|
// grantable) are NOT the code path this takes.
|
|
func TestSec_Matrix_AnEmptiedOrgGrantsNothingToAnybody(t *testing.T) {
|
|
h, srv, _, root, cookies, p := sec13mHub(t)
|
|
orgs := srv.Dir.(LocalDirectory).OrgDB
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("dev14d", "wiki/secret.md", "the org's content")
|
|
|
|
// Positive control: while the org has members, everything works.
|
|
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, cookies["bob"]); rec.Code != 200 {
|
|
t.Fatalf("fixture: member read: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec := doAs(t, h, "POST", "/api/p/"+p.ID+"/shares", map[string]string{"path": "wiki/secret.md"}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: mint share: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var sh struct {
|
|
Token string `json:"token"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &sh)
|
|
if r := doHTTP(h, httptest.NewRequest("GET", "/s/"+sh.Token, nil)); r.Code != 200 {
|
|
t.Fatalf("fixture: the fresh link should serve: %d", r.Code)
|
|
}
|
|
rec = doAs(t, h, "POST", "/api/orgs/"+p.Org+"/invites", nil, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: mint invite: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var inv struct {
|
|
Token string `json:"token"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &inv)
|
|
|
|
// Empty the org the only way the hub allows: remove the plain members,
|
|
// then delete the sole owner's account (offboard -> EvictMember, and with
|
|
// nobody left there is no heir).
|
|
for _, who := range []string{"bob", "carol"} {
|
|
if r := doAs(t, h, "DELETE", "/api/orgs/"+p.Org+"/members/"+who+"@x.io", nil, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("remove %s: %d %s", who, r.Code, r.Body)
|
|
}
|
|
}
|
|
sec14mDeleteAccount(t, srv, "alice@x.io")
|
|
if o, ok := orgs.Get(p.Org); !ok || len(o.Members) != 0 {
|
|
t.Fatalf("fixture: the org should be empty, got ok=%v members=%v", ok, o.Members)
|
|
}
|
|
|
|
// Nothing in it may be reachable by anyone.
|
|
for _, who := range []string{"bob", "carol"} {
|
|
for _, u := range []string{"/api/p/" + p.ID + "/tree", "/api/p/" + p.ID + "/history",
|
|
"/api/p/" + p.ID + "/store/list", "/api/p/" + p.ID + "/permissions"} {
|
|
if r := doAs(t, h, "GET", u, nil, cookies[who]); r.Code == 200 {
|
|
t.Errorf("%s still reads %s of a project in an org with no members: %s", who, u, r.Body)
|
|
}
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/projects",
|
|
map[string]string{"name": "wiki", "org": p.Org}, cookies[who]); r.Code != http.StatusForbidden {
|
|
t.Errorf("%s can still create/join a project in an org with no members: %d %s", who, r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/orgs/"+p.Org+"/invites", nil, cookies[who]); r.Code != http.StatusForbidden {
|
|
t.Errorf("%s can still mint an invite to an org with no members: %d %s", who, r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/orgs", nil, cookies[who]); strings.Contains(r.Body.String(), p.Org) {
|
|
t.Errorf("%s is still shown an org they do not belong to: %s", who, r.Body)
|
|
}
|
|
}
|
|
// The public link the departed owner left behind must be dead.
|
|
if r := doHTTP(h, httptest.NewRequest("GET", "/s/"+sh.Token, nil)); r.Code == 200 {
|
|
t.Errorf("a public share link still serves an emptied org's content to anonymous strangers")
|
|
}
|
|
// And so must the invite, which on the default invite-only posture also
|
|
// bootstraps an account.
|
|
if r := doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, cookies["carol"]); r.Code == 200 {
|
|
t.Errorf("an invite minted by the departed owner still joins strangers to the emptied org: %s", r.Body)
|
|
}
|
|
}
|
|
|
|
// ---- new column: project delete, beyond its share links ------------------
|
|
|
|
// Round 13 proved /s/ links die when the project is deleted, and named the
|
|
// mechanism: projectVolume fails the lookup, NOT a sweep of the share rows.
|
|
// Everything else the delete leaves behind was never probed. This drives the
|
|
// whole surface as the project's own ADMIN — the strongest principal there is
|
|
// — so a residue that answers is not "an outsider guessed an id", it is a
|
|
// deleted project still being a project.
|
|
func TestSec_Matrix_ProjectDeleteLeavesNoReachableResidue(t *testing.T) {
|
|
h, srv, _, root, cookies, p := sec13mHub(t)
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("dev14e", "wiki/secret.md", "the project's content")
|
|
|
|
rec := doAs(t, h, "POST", "/api/p/"+p.ID+"/shares", map[string]string{"path": "wiki/secret.md"}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: mint share: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var sh struct {
|
|
Token string `json:"token"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &sh)
|
|
|
|
reads := []string{
|
|
"/api/p/" + p.ID + "/tree",
|
|
"/api/p/" + p.ID + "/file?path=wiki/secret.md",
|
|
"/api/p/" + p.ID + "/download?path=wiki/secret.md",
|
|
"/api/p/" + p.ID + "/render?path=wiki/secret.md",
|
|
"/api/p/" + p.ID + "/history",
|
|
"/api/p/" + p.ID + "/shares",
|
|
"/api/p/" + p.ID + "/permissions",
|
|
"/api/p/" + p.ID + "/store/list",
|
|
"/api/projects/" + p.ID,
|
|
}
|
|
// Positive control: alice reaches all of it.
|
|
for _, u := range reads {
|
|
if r := doAs(t, h, "GET", u, nil, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("fixture: alice cannot GET %s: %d %s", u, r.Code, r.Body)
|
|
}
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/orgs/"+p.Org+"/shares", nil, cookies["alice"]); !strings.Contains(r.Body.String(), sh.Token) {
|
|
t.Fatalf("fixture: the org share audit should list the link: %s", r.Body)
|
|
}
|
|
|
|
if r := doAs(t, h, "DELETE", "/api/projects/"+p.ID, nil, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("delete project: %d %s", r.Code, r.Body)
|
|
}
|
|
|
|
for _, u := range reads {
|
|
if r := doAs(t, h, "GET", u, nil, cookies["alice"]); r.Code == 200 {
|
|
t.Errorf("a DELETED project still answers GET %s to its former admin: %s", u, r.Body)
|
|
}
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/projects", nil, cookies["alice"]); strings.Contains(r.Body.String(), p.ID) {
|
|
t.Errorf("a deleted project is still in the project list: %s", r.Body)
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/orgs/"+p.Org+"/shares", nil, cookies["alice"]); strings.Contains(r.Body.String(), sh.Token) {
|
|
t.Errorf("the org-wide share audit still lists a deleted project's link: %s", r.Body)
|
|
}
|
|
if r := doHTTP(h, httptest.NewRequest("GET", "/s/"+sh.Token, nil)); r.Code == 200 {
|
|
t.Errorf("a deleted project's public link still serves its content")
|
|
}
|
|
// Write doors too — the storage prefix is deliberately left in place, so
|
|
// nothing may still be able to add to it.
|
|
if r := sec14mStorePut(t, h, p.ID, "journal/dev14e.jsonl", "dev14e", []byte("\n"), cookies["alice"]); r.Code == 200 {
|
|
t.Errorf("a deleted project still accepts a journal push: %s", r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/p/"+p.ID+"/upload/init",
|
|
map[string]any{"path": "x.md", "size": 1}, cookies["alice"]); r.Code == 200 {
|
|
t.Errorf("a deleted project still accepts an upload: %s", r.Body)
|
|
}
|
|
// Re-creating the same NAME must not re-open the old storage prefix.
|
|
rec = doAs(t, h, "POST", "/api/projects", map[string]string{"name": p.Name}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("recreate: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
Created bool `json:"created"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if !out.Created || out.Project.ID == p.ID {
|
|
t.Errorf("re-creating a deleted project's name reused its id (%s): the retired "+
|
|
"storage prefix comes back with it", out.Project.ID)
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/p/"+out.Project.ID+"/file?path=wiki/secret.md", nil, cookies["alice"]); r.Code == 200 {
|
|
t.Errorf("the new project serves the deleted one's content: %s", r.Body)
|
|
}
|
|
_ = srv
|
|
}
|
|
|
|
// ---- new column: org rename ----------------------------------------------
|
|
|
|
// Nothing on the hub is supposed to be keyed on an org's NAME — create-or-join
|
|
// is name-scoped per org ID, membership is by id, share liveness resolves the
|
|
// id. There was no column for it, so nothing said so. A rename that moved any
|
|
// of those would be a silent re-authorization triggered by a label edit.
|
|
func TestSec_Matrix_OrgRenameMovesNoGrant(t *testing.T) {
|
|
h, srv, _, root, cookies, p := sec13mHub(t)
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("dev14f", "wiki/secret.md", "content")
|
|
|
|
rec := doAs(t, h, "POST", "/api/p/"+p.ID+"/shares", map[string]string{"path": "wiki/secret.md"}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: mint share: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var sh struct {
|
|
Token string `json:"token"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &sh)
|
|
// Carol is denied this project explicitly.
|
|
if r := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/carol@x.io",
|
|
map[string]string{"level": PermNone}, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("fixture: deny carol: %d %s", r.Code, r.Body)
|
|
}
|
|
|
|
// Only an owner may rename.
|
|
for _, who := range []string{"bob", "carol"} {
|
|
if r := doAs(t, h, "PATCH", "/api/orgs/"+p.Org, map[string]string{"name": "pwned"}, cookies[who]); r.Code != http.StatusForbidden {
|
|
t.Errorf("plain member %s renamed the org: %d %s", who, r.Code, r.Body)
|
|
}
|
|
}
|
|
if r := doAs(t, h, "PATCH", "/api/orgs/"+p.Org, map[string]string{"name": "Renamed Org"}, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("owner rename: %d %s", r.Code, r.Body)
|
|
}
|
|
|
|
// Everything the rename must not have touched.
|
|
if lvl := srv.projectPerm(sec13mAs(cookies["carol"]), p.ID); lvl != PermNone {
|
|
t.Errorf("after the org rename carol's level is %q, not the %q she was denied", lvl, PermNone)
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, cookies["carol"]); r.Code != http.StatusForbidden {
|
|
t.Errorf("an org rename gave a denied member read access back: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, cookies["bob"]); r.Code != 200 {
|
|
t.Errorf("an org rename took an ordinary member's access away: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doHTTP(h, httptest.NewRequest("GET", "/s/"+sh.Token, nil)); r.Code != 200 {
|
|
t.Errorf("an org rename killed a live share link: %d", r.Code)
|
|
}
|
|
// Create-or-join by name still resolves the SAME project (names are scoped
|
|
// by org id, not by org name) — and still refuses the denied member.
|
|
rec = doAs(t, h, "POST", "/api/projects", map[string]string{"name": p.Name}, cookies["alice"])
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
Created bool `json:"created"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if out.Created || out.Project.ID != p.ID {
|
|
t.Errorf("after the org rename, create-or-join by name minted a NEW project %s (created=%v) "+
|
|
"instead of resolving %s: project names are scoped by org id, and a second row with the "+
|
|
"same name splits the team", out.Project.ID, out.Created, p.ID)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/projects", map[string]string{"name": p.Name}, cookies["carol"]); r.Code != http.StatusForbidden {
|
|
t.Errorf("after the org rename create-or-join handed the denied member the project id: %d %s", r.Code, r.Body)
|
|
}
|
|
}
|
|
|
|
// ---- cell: template seeding x revocation ---------------------------------
|
|
|
|
// seedTemplate's writes had never been touched by a revocation test in
|
|
// fourteen rounds. It is reachable only from handleProjectCreate's `created`
|
|
// branch, so the questions are (a) can a principal who has been cut off reach
|
|
// it at all, and (b) can the create-or-join path be used to seed INTO an
|
|
// existing project — which would be a write door with no PermWrite on it.
|
|
func TestSec_Matrix_TemplateSeedingIsNotAWriteDoorIntoAnExistingProject(t *testing.T) {
|
|
h, srv, _, _, cookies, p := sec13mHub(t)
|
|
|
|
// Alice seeds a real template into a project of her own: the positive
|
|
// control that seeding works at all.
|
|
rec := doAs(t, h, "POST", "/api/projects",
|
|
map[string]string{"name": "seeded", "template": "docs"}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: seed a new project: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var seeded struct {
|
|
Project Project `json:"project"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &seeded)
|
|
before := doAs(t, h, "GET", "/api/p/"+seeded.Project.ID+"/tree", nil, cookies["alice"]).Body.String()
|
|
if !strings.Contains(before, ".md") {
|
|
t.Fatalf("fixture: the template seeded nothing: %s", before)
|
|
}
|
|
|
|
// (a) A member removed from the org cannot create-and-seed in it.
|
|
if r := doAs(t, h, "DELETE", "/api/orgs/"+p.Org+"/members/bob@x.io", nil, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("remove bob: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/projects",
|
|
map[string]string{"name": "bobs-seed", "org": p.Org, "template": "docs"}, cookies["bob"]); r.Code == 200 {
|
|
t.Errorf("a removed org member created and seeded a project in the org he was removed from: %s", r.Body)
|
|
}
|
|
|
|
// (b) Create-or-join with a template against an EXISTING project must
|
|
// neither seed nor be an unpermissioned write. Carol is denied it.
|
|
if r := doAs(t, h, "PUT", "/api/p/"+seeded.Project.ID+"/permissions/carol@x.io",
|
|
map[string]string{"level": PermNone}, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("deny carol: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/projects",
|
|
map[string]string{"name": "seeded", "org": p.Org, "template": "para"}, cookies["carol"]); r.Code != http.StatusForbidden {
|
|
t.Errorf("a denied member reached an existing project through create-or-join with a template: %d %s",
|
|
r.Code, r.Body)
|
|
}
|
|
after := doAs(t, h, "GET", "/api/p/"+seeded.Project.ID+"/tree", nil, cookies["alice"]).Body.String()
|
|
if after != before {
|
|
t.Errorf("create-or-join with a template CHANGED an existing project's tree — a write door "+
|
|
"with no PermWrite on it.\nbefore: %s\nafter: %s", before, after)
|
|
}
|
|
// And a read-only member gets the same answer: the project, unchanged.
|
|
if r := doAs(t, h, "PUT", "/api/p/"+seeded.Project.ID+"/permissions/carol@x.io",
|
|
map[string]string{"level": PermRead}, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("grant carol read: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/projects",
|
|
map[string]string{"name": "seeded", "org": p.Org, "template": "para"}, cookies["carol"]); r.Code != 200 {
|
|
t.Fatalf("read-only create-or-join: %d %s", r.Code, r.Body)
|
|
}
|
|
if now := doAs(t, h, "GET", "/api/p/"+seeded.Project.ID+"/tree", nil, cookies["alice"]).Body.String(); now != before {
|
|
t.Errorf("a read-only member re-seeded an existing project through create-or-join:\n%s", now)
|
|
}
|
|
_ = srv
|
|
}
|
|
|
|
// ---- cell: storage reservations x revocation -----------------------------
|
|
|
|
// reserve.go's grants had never been crossed with any revocation. The grant
|
|
// itself is accounting, but the CAPABILITY it accompanies is a presigned URL
|
|
// that writes straight into the object store past every check this package
|
|
// makes — so the door that mints one has to close the moment write does, and a
|
|
// grant booked for one project must never be settled by a request naming
|
|
// another.
|
|
func TestSec_Matrix_ASignedUploadGrantDiesWithTheWritePermission(t *testing.T) {
|
|
h, srv, _, _, cookies, p := sec13mHub(t)
|
|
key := "blobs/" + strings.Repeat("c", 64)
|
|
org := p.Org
|
|
|
|
// Positive control: bob has write, so the sign door answers him.
|
|
if r := doAs(t, h, "POST", "/api/p/"+p.ID+"/store/sign",
|
|
map[string]any{"key": key, "size": 4096}, cookies["bob"]); r.Code != 200 {
|
|
t.Fatalf("fixture: bob should reach the sign door: %d %s", r.Code, r.Body)
|
|
}
|
|
beforeReserved := srv.reservedBytes(org)
|
|
|
|
// Demote bob to read on the project.
|
|
if r := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/bob@x.io",
|
|
map[string]string{"level": PermRead}, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("demote bob: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/p/"+p.ID+"/store/sign",
|
|
map[string]any{"key": key, "size": 4096}, cookies["bob"]); r.Code != http.StatusForbidden {
|
|
t.Errorf("a read-only member still mints a direct-to-storage upload grant: %d %s", r.Code, r.Body)
|
|
}
|
|
// Removed from the org entirely: same answer.
|
|
if r := doAs(t, h, "DELETE", "/api/orgs/"+p.Org+"/members/bob@x.io", nil, cookies["alice"]); r.Code != 200 {
|
|
t.Fatalf("remove bob: %d %s", r.Code, r.Body)
|
|
}
|
|
if r := doAs(t, h, "POST", "/api/p/"+p.ID+"/store/sign",
|
|
map[string]any{"key": key, "size": 4096}, cookies["bob"]); r.Code != http.StatusForbidden {
|
|
t.Errorf("a removed org member still mints a direct-to-storage upload grant: %d %s", r.Code, r.Body)
|
|
}
|
|
// The revocation must not have SETTLED the outstanding grant either: bytes
|
|
// that arrive through a URL already handed out still have to be charged.
|
|
if now := srv.reservedBytes(org); now < beforeReserved {
|
|
t.Errorf("revoking the minter's write released its outstanding reservation "+
|
|
"(%d -> %d): the presigned URL is still live, so those bytes become free", beforeReserved, now)
|
|
}
|
|
// A grant is keyed by (project, key): another project must not settle it.
|
|
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "other", "org": p.Org}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("second project: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var other struct {
|
|
Project Project `json:"project"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &other)
|
|
if !srv.claimGrant(other.Project.ID, key) {
|
|
// The desired outcome: nothing to claim over there.
|
|
} else {
|
|
t.Errorf("a grant booked for project %s was settled by naming project %s", p.ID, other.Project.ID)
|
|
}
|
|
if now := srv.reservedBytes(org); now != beforeReserved {
|
|
t.Errorf("a foreign project's claim moved the org's reserved total: %d -> %d", beforeReserved, now)
|
|
}
|
|
}
|
|
|
|
// ---- cell: read-ledger buckets x offboarding -----------------------------
|
|
|
|
// Round 13 asserted /heat never NAMES a departed member on the default query.
|
|
// The two paths it did not take are the ones an actor id survives longest in:
|
|
// the all-time fold (?days=0, the rows retention keeps forever) and the
|
|
// ?by=device axis. Both are re-checked here after the account is deleted AND
|
|
// after a hub restart that reloads the buckets from disk, because the buckets
|
|
// themselves are never swept.
|
|
func TestSec_Matrix_NoHeatQueryNamesADepartedMemberAfterARestart(t *testing.T) {
|
|
dir, root := t.TempDir(), t.TempDir()
|
|
srv := sec13mServer(t, dir, root)
|
|
readsPath := filepath.Join(dir, "reads.json")
|
|
ledger, err := OpenReadLedger(readsPath, 400)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Reads = ledger
|
|
h := srv.Handler()
|
|
cookies := map[string]*http.Cookie{}
|
|
for _, who := range []string{"alice", "bob"} {
|
|
cookies[who] = signupAndSession(t, h, who+"@x.io", strings.ToUpper(who[:1])+who[1:], "password1")
|
|
}
|
|
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "wiki"}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: create project: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
p := out.Project
|
|
orgs := srv.Dir.(LocalDirectory).OrgDB
|
|
if err := orgs.AddMember(p.Org, "bob@x.io", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("dev14i", "wiki/secret.md", "content")
|
|
|
|
// Bob reads the file (human bucket, actor = his email) and reports an
|
|
// agent read from his own device (agent bucket, actor = the device id).
|
|
if err := srv.bindDevice("bob@x.io", sec14mBindReq("devbob14i", "bob-laptop")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := doAs(t, h, "GET", "/api/p/"+p.ID+"/file?path=wiki/secret.md", nil, cookies["bob"]); r.Code != 200 {
|
|
t.Fatalf("fixture: bob read: %d %s", r.Code, r.Body)
|
|
}
|
|
rr := httptestNewRequestBody("POST", "/api/p/"+p.ID+"/reads",
|
|
[]byte(`{"reads":[{"path":"wiki/secret.md"}]}`))
|
|
rr.Header.Set("X-Bdrive-Device", "devbob14i")
|
|
rr.AddCookie(cookies["bob"])
|
|
if r := doHTTP(h, rr); r.Code != 200 {
|
|
t.Fatalf("fixture: agent read report: %d %s", r.Code, r.Body)
|
|
}
|
|
// Age one bucket past retention so the all-time fold is exercised for real.
|
|
ledger.mu.Lock()
|
|
for k, st := range ledger.byKey {
|
|
if k.Kind != ReadKindHuman {
|
|
continue
|
|
}
|
|
fold := k
|
|
fold.Day = ""
|
|
st.Day = ""
|
|
ledger.byKey[fold] = st
|
|
ledger.dirty[fold] = true
|
|
delete(ledger.byKey, k)
|
|
}
|
|
ledger.mu.Unlock()
|
|
if err := ledger.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sec14mDeleteAccount(t, srv, "bob@x.io")
|
|
|
|
// A fresh hub process over the same reads.json: the buckets are never
|
|
// swept, so this is the state an operator actually queries months later.
|
|
srv2 := sec13mServer(t, dir, root)
|
|
l2, err := OpenReadLedger(readsPath, 400)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv2.Reads = l2
|
|
h2 := srv2.Handler()
|
|
c2 := sec14mLogin(t, h2, "alice@x.io", "password1")
|
|
for _, u := range []string{
|
|
"/api/p/" + p.ID + "/heat",
|
|
"/api/p/" + p.ID + "/heat?days=0",
|
|
"/api/p/" + p.ID + "/heat?prefix=wiki&days=0",
|
|
"/api/p/" + p.ID + "/heat?by=device&days=0",
|
|
} {
|
|
r := doAs(t, h2, "GET", u, nil, c2)
|
|
if r.Code != 200 {
|
|
t.Fatalf("heat %s: %d %s", u, r.Code, r.Body)
|
|
}
|
|
if strings.Contains(strings.ToLower(r.Body.String()), "bob@x.io") {
|
|
t.Errorf("GET %s names a DELETED account as a reader: %s", u, r.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- new column: create-or-join by name, on a second hub process ---------
|
|
|
|
// ProjectDB got refresh() in round 12 and OrgDB's MUTATORS got it in round 13,
|
|
// on the stated rule that a decision made from a map taken at boot is not a
|
|
// decision at all. GetOrCreate is the one mutator inside the struct that got
|
|
// the fix which still scans db.byID with no refresh — and it is the decision
|
|
// behind create-or-JOIN: `bdrive init --project`, the Connect guide, and the
|
|
// hub's own create dialog all reach a teammate's project by NAME through it.
|
|
//
|
|
// On two hub processes in front of one registry, the process that has not seen
|
|
// the project answers "no such name" and mints a SECOND row with the same name
|
|
// in the same org, with the caller as its creator and admin. The team is then
|
|
// silently split across two projects with one name, and the row that decides
|
|
// who may join which is whichever process served the request.
|
|
func TestSec_Matrix_CreateOrJoinByNameIsHonouredOnEveryHubProcess(t *testing.T) {
|
|
hA, _, dir, root, cookiesA, p := sec13mHub(t)
|
|
_ = hA
|
|
|
|
// A second hub process, up now: it has bob's membership and alice's
|
|
// project, so a join by name must resolve to exactly the same id.
|
|
srvB := sec13mServer(t, dir, root)
|
|
hB := srvB.Handler()
|
|
bobB := sec14mLogin(t, hB, "bob@x.io", "password1")
|
|
|
|
// Positive control: process B resolves the project fine when asked by id.
|
|
if r := doAs(t, hB, "GET", "/api/p/"+p.ID+"/tree", nil, bobB); r.Code != 200 {
|
|
t.Fatalf("fixture: process B cannot read the project: %d %s", r.Code, r.Body)
|
|
}
|
|
|
|
// Alice creates a SECOND project on process A. B has never seen it.
|
|
rec := doAs(t, hA, "POST", "/api/projects", map[string]string{"name": "handbook"}, cookiesA["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("fixture: create handbook: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var made struct {
|
|
Project Project `json:"project"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &made)
|
|
|
|
// Bob joins it by name on process B — the real onboarding path.
|
|
rec = doAs(t, hB, "POST", "/api/projects",
|
|
map[string]string{"name": "handbook", "org": p.Org}, bobB)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("join by name: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var joined struct {
|
|
Project Project `json:"project"`
|
|
Created bool `json:"created"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &joined)
|
|
if joined.Created || joined.Project.ID != made.Project.ID {
|
|
t.Errorf("joining %q by name on a second hub process minted a NEW project %s "+
|
|
"(created=%v) instead of resolving %s: GetOrCreate scans a registry map taken at "+
|
|
"boot, so create-or-join answers differently on every replica and the team is split "+
|
|
"across two projects with one name.", "handbook", joined.Project.ID, joined.Created, made.Project.ID)
|
|
}
|
|
}
|
|
|
|
// Both second-process defects above live in the SERVICE struct, not in a repo,
|
|
// so they are backend-independent — the shape round 13 said a fix aimed at
|
|
// db_file.go would miss. Pinned across every backend the run can reach so a
|
|
// fix cannot land in one of them and read as done.
|
|
func TestSec_Matrix_StaleServiceMapsAreNotHonouredOnAnySQLBackend(t *testing.T) {
|
|
for _, be := range metaBackends(t) {
|
|
t.Run(be.name, func(t *testing.T) {
|
|
be.reset(t)
|
|
s := be.open(t)
|
|
t.Cleanup(func() { s.Close() })
|
|
|
|
// --- device release ---
|
|
a, err := NewDeviceRegistry(s.Devices())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.Bind("bob@x.io", DeviceInfo{ID: "devbob14s", Name: "bob-laptop"}, sec13mAll); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := NewDeviceRegistry(s.Devices()) // the second hub process
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if owner, _ := b.OwnerOf("devbob14s"); owner != "bob@x.io" {
|
|
t.Fatalf("fixture: process B should see the claim, got %q", owner)
|
|
}
|
|
a.Release("bob@x.io")
|
|
if owner, _ := b.OwnerOf("devbob14s"); owner != "" {
|
|
t.Errorf("after Release on process A, process B still says %q owns the device: "+
|
|
"DeviceRegistry.byKey is loaded at open and never re-read", owner)
|
|
}
|
|
|
|
// --- create-or-join by name ---
|
|
pa, err := NewProjectDB(s.Projects())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pb, err := NewProjectDB(s.Projects()) // the second hub process
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
made, created, err := pa.GetOrCreate("handbook14", "org14")
|
|
if err != nil || !created {
|
|
t.Fatalf("fixture: create: %v created=%v", err, created)
|
|
}
|
|
joined, createdB, err := pb.GetOrCreate("handbook14", "org14")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if createdB || joined.ID != made.ID {
|
|
t.Errorf("create-or-join by name minted a second %q in the same org on process B "+
|
|
"(%s vs %s): GetOrCreate is the one mutator in the struct that got refresh() "+
|
|
"which still scans a map taken at boot", "handbook14", joined.ID, made.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- new column: reopening the store (migrations re-run, populated) ------
|
|
|
|
// A hub restart re-runs the schema migration against populated tables, and
|
|
// every registry rebuilds its in-memory map from what it finds. Nothing had
|
|
// ever asserted that a REVOCATION survives that — only that grants do
|
|
// (TestMetaStoreConformance). A revocation that does not survive a restart is
|
|
// the same hole as one that does not cross a process, arriving on a schedule
|
|
// instead of by accident, and it is the one an operator will never notice.
|
|
//
|
|
// Every registry, every backend the run can reach.
|
|
func TestSec_Matrix_ReopeningTheStoreResurrectsNoRevocation(t *testing.T) {
|
|
for _, be := range metaBackends(t) {
|
|
t.Run(be.name, func(t *testing.T) {
|
|
be.reset(t)
|
|
s1 := be.open(t)
|
|
|
|
projects, err := NewProjectDB(s1.Projects())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
orgs, err := NewOrgDB(s1.Orgs())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shares, err := NewShareDB(s1.Shares())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
devices, err := NewDeviceRegistry(s1.Devices())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auth, err := NewBuiltinAuth(s1.Accounts(), true, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
org, err := orgs.Create("acme14", "alice@x.io")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := orgs.AddMember(org.ID, "bob@x.io", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _, err := projects.GetOrCreate("wiki14", org.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := projects.SetPerm(p.ID, "bob@x.io", PermAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := projects.SetPerm(p.ID, "carol@x.io", PermAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inv, err := orgs.CreateInvite(org.ID, "alice@x.io", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sh, err := shares.Create(p.ID, "wiki/secret.md", "alice@x.io", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := devices.Bind("bob@x.io", DeviceInfo{ID: "devbob14r", Name: "bob-laptop"}, sec13mAll); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := auth.signup("bob@x.io", "Bob", "password1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, err := auth.issueToken(u.ID, "devbob14r")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Positive control: every grant is real before the revocations.
|
|
if projects.byIDLevel(p.ID, "bob@x.io") != PermAdmin ||
|
|
orgs.Role(org.ID, "bob@x.io") != RoleMember ||
|
|
!orgs.ValidInvite(inv.Token) {
|
|
t.Fatal("fixture: grants did not take")
|
|
}
|
|
if _, ok := shares.Get(sh.Token); !ok {
|
|
t.Fatal("fixture: the share should be live")
|
|
}
|
|
if owner, _ := devices.OwnerOf("devbob14r"); owner != "bob@x.io" {
|
|
t.Fatalf("fixture: device owner %q", owner)
|
|
}
|
|
if _, ok := auth.userForToken(tok); !ok {
|
|
t.Fatal("fixture: the token should authenticate")
|
|
}
|
|
|
|
// Every revocation route this hub has.
|
|
if err := projects.SetPerm(p.ID, "bob@x.io", PermNone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := orgs.RemoveMember(org.ID, "bob@x.io"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !orgs.RevokeInvite(inv.Token) {
|
|
t.Fatal("revoke invite")
|
|
}
|
|
if !shares.Revoke(sh.Token) {
|
|
t.Fatal("revoke share")
|
|
}
|
|
devices.Release("bob@x.io")
|
|
if err := auth.revokeToken(tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s1.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The restart: migrations re-run against populated tables, and
|
|
// every registry reloads.
|
|
s2 := be.open(t)
|
|
t.Cleanup(func() { s2.Close() })
|
|
projects2, err := NewProjectDB(s2.Projects())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
orgs2, err := NewOrgDB(s2.Orgs())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shares2, err := NewShareDB(s2.Shares())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
devices2, err := NewDeviceRegistry(s2.Devices())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auth2, err := NewBuiltinAuth(s2.Accounts(), true, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if lvl := projects2.byIDLevel(p.ID, "bob@x.io"); lvl != PermNone {
|
|
t.Errorf("after the restart bob's project grant is %q, not the %q he was demoted to",
|
|
lvl, PermNone)
|
|
}
|
|
if role := orgs2.Role(org.ID, "bob@x.io"); role != "" {
|
|
t.Errorf("after the restart bob is back in the org as %q", role)
|
|
}
|
|
if orgs2.ValidInvite(inv.Token) {
|
|
t.Errorf("a revoked org invite is live again after a restart — on the default "+
|
|
"invite-only posture that link also bootstraps accounts (%s)", inv.Token)
|
|
}
|
|
if _, ok := shares2.Get(sh.Token); ok {
|
|
t.Errorf("a revoked public /s/ link is live again after a restart (%s)", sh.Token)
|
|
}
|
|
if owner, _ := devices2.OwnerOf("devbob14r"); owner != "" {
|
|
t.Errorf("a released device claim is back after a restart, owned by %q", owner)
|
|
}
|
|
if _, ok := auth2.userForToken(tok); ok {
|
|
t.Errorf("a revoked device token authenticates again after a restart")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- new column: device re-registration ----------------------------------
|
|
|
|
// A journal is an APPEND-ONLY log — that is the repo's stated data model and
|
|
// the reason History can answer "who changed this file?" at all. The hub never
|
|
// enforces it: /store/object is a plain object PUT, so the writer of a journal
|
|
// can replace it with a SHORTER one and every op it held is gone from replay,
|
|
// from every peer, and from the hub's only audit surface.
|
|
//
|
|
// Two principals reach that, and the second is the device-re-registration cell
|
|
// this round was asked to open:
|
|
//
|
|
// - the device's own account, erasing its own trail;
|
|
// - and, after offboarding releases the id and the machine is reassigned,
|
|
// whoever inherits it — deleting the record of what the departed member
|
|
// did, with ordinary permissions and one request.
|
|
func TestSec_Matrix_AJournalPushCannotEraseOpsTheHubAlreadyHolds(t *testing.T) {
|
|
h, srv, _, _, cookies, p := sec13mHub(t)
|
|
const id = "devbob14h"
|
|
blob := strings.Repeat("a", 64)
|
|
|
|
if err := srv.bindDevice("bob@x.io", sec14mBindReq(id, "bob-laptop")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
full := secaudOpLine(1, id, "put", "wiki/one.md", blob) +
|
|
secaudOpLine(2, id, "put", "wiki/two.md", blob) +
|
|
secaudOpLine(3, id, "put", "wiki/three.md", blob)
|
|
if r := sec14mStorePut(t, h, p.ID, "journal/"+id+".jsonl", id, []byte(full), cookies["bob"]); r.Code != 200 {
|
|
t.Fatalf("fixture: bob's journal push: %d %s", r.Code, r.Body)
|
|
}
|
|
// Positive control: all three changes are in the audit surface.
|
|
count := func(who string) int {
|
|
r := doAs(t, h, "GET", "/api/p/"+p.ID+"/history", nil, cookies[who])
|
|
if r.Code != 200 {
|
|
t.Fatalf("history: %d %s", r.Code, r.Body)
|
|
}
|
|
var out struct {
|
|
Entries []HistoryEntry `json:"entries"`
|
|
}
|
|
json.Unmarshal(r.Body.Bytes(), &out)
|
|
return len(out.Entries)
|
|
}
|
|
if n := count("alice"); n != 3 {
|
|
t.Fatalf("fixture: history has %d entries, want 3", n)
|
|
}
|
|
|
|
// The device's own account rewinds its log to one op.
|
|
short := secaudOpLine(1, id, "put", "wiki/one.md", blob)
|
|
rec := sec14mStorePut(t, h, p.ID, "journal/"+id+".jsonl", id, []byte(short), cookies["bob"])
|
|
if rec.Code == 200 && count("alice") < 3 {
|
|
t.Errorf("a member erased %d of his own changes from the hub's audit trail with one "+
|
|
"object PUT: the journal is append-only in the data model and nowhere else, so "+
|
|
"/store/object accepts a shorter log and every peer replays the truncated one",
|
|
3-count("alice"))
|
|
}
|
|
|
|
// The re-registration case: the account is offboarded, the machine is
|
|
// reassigned, and the new holder rewinds the DEPARTED member's log.
|
|
// Put the full log back first, so this half measures its own delta
|
|
// whichever way the half above resolved.
|
|
if r := sec14mStorePut(t, h, p.ID, "journal/"+id+".jsonl", id, []byte(full), cookies["bob"]); r.Code != 200 {
|
|
t.Fatalf("fixture: restoring bob's journal: %d %s", r.Code, r.Body)
|
|
}
|
|
sec14mDeleteAccount(t, srv, "bob@x.io")
|
|
if err := srv.bindDevice("carol@x.io", sec14mBindReq(id, "carol-pc")); err != nil {
|
|
t.Fatalf("the reassigned laptop must be bindable: %v", err)
|
|
}
|
|
before := count("alice")
|
|
rec = sec14mStorePut(t, h, p.ID, "journal/"+id+".jsonl", id,
|
|
[]byte(secaudOpLine(1, id, "put", "wiki/carol.md", blob)), cookies["carol"])
|
|
if rec.Code == 200 && count("alice") < before {
|
|
t.Errorf("after inheriting a departed member's device id, carol deleted %d of that "+
|
|
"member's changes from History with one journal PUT", before-count("alice"))
|
|
}
|
|
}
|
|
|
|
// ---- new column: `bdrive logout`, end to end through the real CLI --------
|
|
|
|
// Only the HTTP route (DELETE /api/auth/token) had ever been driven. The
|
|
// command is what an operator actually runs on a laptop that is going back to
|
|
// IT, and the whole point of it is that the credential dies ON THE HUB, not
|
|
// just in $BDRIVE_HOME — a local-only clear leaves a live bearer token in
|
|
// whatever backup, shell history or disk image the file was already in.
|
|
func TestSec_Matrix_CLILogoutKillsTheTokenOnTheHubNotJustOnDisk(t *testing.T) {
|
|
e := newCLIEnv(t)
|
|
settings := filepath.Join(e.home, ".bdrive", "settings.json")
|
|
raw, err := os.ReadFile(settings)
|
|
if err != nil {
|
|
t.Fatalf("settings.json after login: %v", err)
|
|
}
|
|
var before struct {
|
|
Token string `json:"token"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := json.Unmarshal(raw, &before); err != nil || before.Token == "" {
|
|
t.Fatalf("login stored no token: %v %s", err, raw)
|
|
}
|
|
|
|
bearer := func(tok string) int {
|
|
req, err := http.NewRequest("GET", e.hub.URL+"/api/projects", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
// Positive control: the token the CLI holds really is a hub credential.
|
|
if code := bearer(before.Token); code != 200 {
|
|
t.Fatalf("fixture: the logged-in token should reach the hub: %d", code)
|
|
}
|
|
|
|
out, err := e.run(t.TempDir(), "logout")
|
|
if err != nil {
|
|
t.Fatalf("bdrive logout: %v\n%s", err, out)
|
|
}
|
|
|
|
if code := bearer(before.Token); code == 200 {
|
|
t.Errorf("`bdrive logout` left the device token LIVE on the hub (%d): the command "+
|
|
"cleared $BDRIVE_HOME and the credential in every backup of that file still "+
|
|
"syncs.\noutput:\n%s", code, out)
|
|
}
|
|
after, err := os.ReadFile(settings)
|
|
if err != nil {
|
|
t.Fatalf("settings.json after logout: %v", err)
|
|
}
|
|
if strings.Contains(string(after), before.Token) {
|
|
t.Errorf("`bdrive logout` left the token in settings.json:\n%s", after)
|
|
}
|
|
if before.Email != "" && strings.Contains(string(after), before.Email) {
|
|
t.Errorf("`bdrive logout` left the signed-in account in settings.json:\n%s", after)
|
|
}
|
|
}
|
|
|
|
// byIDLevel is the explicit grant a project holds for an address, or "" for
|
|
// none — read straight out of the registry so the assertion is about what was
|
|
// persisted, not about a route's resolution.
|
|
func (db *ProjectDB) byIDLevel(id, email string) string {
|
|
p, ok := db.Get(id)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return p.Perms[normEmail(email)]
|
|
}
|