Merge branch 'feat-team-mode' into main
55 files changed,
+3458
−223
cmd/docs/main.go+10 −1fixtures/jump-to.mjs+2 −1go.mod+4 −3go.sum+4 −2implementation/home-principal-store.md+17 −0implementation/team-mode-integration.md+97 −0internal/docs/app.go+51 −4internal/docs/auth.go+5 −2internal/docs/content_runtime_test.go+2 −2internal/docs/export.go+5 −5internal/docs/grants.go+51 −36internal/docs/grants_test.go+6 −6internal/docs/grants_write_test.go+5 −5internal/docs/handlers.go+40 −36internal/docs/home_exchange.go+17 −11internal/docs/home_exchange_test.go+1 −1internal/docs/home_router_integration_test.go+6 −2internal/docs/identity.go+42 −4internal/docs/identity_test.go+2 −2internal/docs/links.go+2 −2internal/docs/principalstore/migrations/0001_principals.sql+47 −0internal/docs/principalstore/store.go+215 −0internal/docs/principalstore/store_test.go+274 −0internal/docs/principalstore/testdata/compat_main.go.txt+102 −0internal/docs/principalstore/testdata/compat_new.go.txt+27 −0internal/docs/principalstore/testdata/compat_old.go.txt+13 −0internal/docs/publish.go+6 −7internal/docs/render.go+2 −5internal/docs/search.go+9 −4internal/docs/share.go+29 −24internal/docs/snapshots.go+4 −1internal/docs/static/jumpto.5da80b47f2ea7b28.js+1 −0internal/docs/static/jumpto.a37448e7fcd21855.js+0 −1internal/docs/store.go+37 −50internal/docs/stream.go+59 −4internal/docs/team_access.go+253 −0internal/docs/team_access_test.go+454 −0internal/docs/team_config.go+124 −0internal/docs/team_emission.go+130 −0internal/docs/team_emission_test.go+82 −0internal/docs/team_home.go+62 −0internal/docs/team_home_test.go+79 −0internal/docs/team_identity_test.go+135 −0internal/docs/team_router_test.go+332 −0internal/docs/team_session_reader.go+27 −0internal/docs/team_session_reader_test.go+104 −0internal/docs/team_stream.go+64 −0internal/docs/team_stream_test.go+153 −0internal/docs/teamstate/migrations/0001_team.sql+10 −0internal/docs/teamstate/store.go+98 −0internal/docs/teamstate/store_test.go+89 −0internal/docs/testing.go+1 −1internal/docs/views/list.html+1 −0internal/docs/views/partials/jumpto.html+1 −1scripts/check-principal-compat.py+65 −0
CI passed — run details
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index 521980f..e640273 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -284,6 +284,15 @@ func main() { |
| if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" { |
| cfg.Home = &home |
| } |
| + |
| + team := docs.TeamConfig{TeamID: os.Getenv("DOCS_TEAM_ID"), DirectoryOrigin: os.Getenv("DOCS_TEAM_DIRECTORY_ORIGIN"), ProvisioningBase64: os.Getenv("DOCS_TEAM_PROVISIONING_BASE64"), OperatorKeysBase64: os.Getenv("DOCS_TEAM_OPERATOR_KEYS_BASE64"), RosterToken: os.Getenv("DOCS_TEAM_ROSTER_TOKEN")} |
| + if team.TeamID != "" || team.DirectoryOrigin != "" || team.ProvisioningBase64 != "" || team.OperatorKeysBase64 != "" || team.RosterToken != "" { |
| + cfg.Team = &team |
| + if seedFlag { |
| + logger.Error("docs: seed unavailable in TeamMode") |
| + os.Exit(1) |
| + } |
| + } |
| app, err := docs.New(cfg) |
| if err != nil { |
| logger.Error("docs: new", "err", err) |
| @@ -315,7 +324,7 @@ func main() { |
| logger.Error("docs: seed: resolve person", "address", seedAddress, "err", err) |
| os.Exit(1) |
| } |
| - if err := docs.Seed(context.Background(), app.Store(), docs.Actor{PersonID: owner, Subject: seedAddress}); err != nil { |
| + if err := docs.Seed(context.Background(), app.Store(), docs.Actor{PersonID: owner, VerifiedAddress: &seedAddress}); err != nil { |
| logger.Error("docs: seed", "address", seedAddress, "err", err) |
| os.Exit(1) |
| } |
diff --git a/fixtures/jump-to.mjs b/fixtures/jump-to.mjs| index ed90bde..414c095 100644 |
| --- a/fixtures/jump-to.mjs |
| +++ b/fixtures/jump-to.mjs |
| @@ -56,6 +56,7 @@ export function createJumpTo(root) { |
| const copy = { |
| empty: status.getAttribute('data-empty') || '', |
| failed: status.getAttribute('data-failed') || '', |
| + partial: status.getAttribute('data-partial') || '', |
| } |
| let open = false |
| @@ -178,7 +179,7 @@ export function createJumpTo(root) { |
| // palette closed, must not overwrite what is on screen. |
| if (mine !== sequence || !open) return |
| render(rows) |
| - setStatus(rows.length === 0 ? copy.empty : '') |
| + setStatus(res.headers?.get('Docs-Catalog-Partial') === '1' ? copy.partial : (rows.length === 0 ? copy.empty : '')) |
| } catch (err) { |
| if (mine !== sequence || !open) return |
| // Said out loud rather than left as an empty list: "no results" |
diff --git a/go.mod b/go.mod| index 2b025da..a3bec91 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,18 +5,20 @@ go 1.26.5 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| github.com/google/uuid v1.6.0 |
| github.com/tetratelabs/wazero v1.12.0 |
| golang.org/x/text v0.20.0 |
| gorm.io/gorm v1.31.2 |
| + modernc.org/sqlite v1.55.0 |
| ) |
| require ( |
| - amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| github.com/keymaildev/signin v0.1.1 // indirect |
| @@ -28,5 +30,4 @@ require ( |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
| - modernc.org/sqlite v1.55.0 // indirect |
| ) |
diff --git a/go.sum b/go.sum| index 69457d9..f458953 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,11 +1,13 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg= |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| -amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA= |
| -amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
| +amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 h1:/vHMeq9T+PZmUACixGkg32YEjyM8s31+bfAqqrMha0A= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
| github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
diff --git a/implementation/home-principal-store.md b/implementation/home-principal-store.md| new file mode 100644 |
| index 0000000..aef3f0e |
| --- /dev/null |
| +++ b/implementation/home-principal-store.md |
| @@ -0,0 +1,17 @@ |
| +# Unmounted Home principal ledger |
| + |
| +This data-only slice implements designa5a791819e65f573a8c329b8cb523771d659c975 (Docs53), based on integration1e862f8. Docs55/task68 owns review. `principalstore.Schema` remains separate and unmounted. No Actor, session, route, roster admission, grant query or production configuration changes are included. |
| + |
| +API: `New(db, Config{Issuer, Team, RequireMembership})`, `Person`, `Resolve`, and `EnsureHome`. Principal exposes ID, kind and an optional verified local address pointer. A Home-only address is nil; it is never an empty or invented people.email. Person/Resolve are identity-only reads. EnsureHome receives an already authenticated, consumed Home identity and refuses creation without the explicit membership-check callback. Constructor verifies foreign_keys is enabled. |
| + |
| +Roster seam agreed with consumer_implementation: request names exact pinned issuer, team and subject. Missing/untrusted/expired/incomplete cache or failed cold refresh maps to ErrUnavailable; a current complete snapshot lacking the member maps to ErrRefused. The callback may refresh before the SQL transaction, never while holding the writer. Success is a bounded observation, not durable authority. No role, epoch, timestamp or standing is stored in principal records; every actual access needs a fresh authority decision in later integration. No session is minted here. |
| + |
| +The SQL migration performs its census and ledger copy in one migration transaction, keeping the original people/link FK tables. A failing census leaves no partial principal schema. Local IDs remain below2^52, Home-only IDs span2^52 through2^53-1. The old people's AFTER INSERT mirror preserves the assigned ID; immutable identity and cross-table mapping guards refuse reuse/reassignment/conflict. Home allocation takes the writer lock before checking either mapping, then inserts the allocator advance/principal/mapping atomically. Neither EnsureHome nor New creates samples or documents. Exhaustion refuses instead of wrapping. New tables have actual FKs; old owner/author fields gain reference-validation triggers while preserving their0 sentinel. |
| + |
| +Focused tests passed with the race detector. They cover typed absence, old/new IDs and LastInsertId, callback errors and missing callback, identity-only resolution, concurrent first arrival, link conflicts, immutable IDs, constraints,0-sentinel updates, foreign_keys, atomic preflight failure, and migration idempotency/older known migration sets. An opaque encoding1 fixture separately proves the migration preserves update/snapshot bytes, encoding flags and key-check/fingerprint bytes without decoding or rewriting them; it does not claim a valid encrypted-document replay test. |
| + |
| +`scripts/check-principal-compat.py --artifacts <new temporary directory>` builds helper executables against actual frozen87662f5,8a126727,3ed7665 and candidate source. The helper calls those versions' real App.New/migrate.Apply, EnsurePerson, Create, ApplyUpdate, Grant and Text methods. Three disposable old→new→old→new cycles preserve local allocations1,2,3,4, Home principal2^52, a Home-owned document, original document sqid/owner/grant author, real Yjs text, update+snapshot hashes and encoding0. The helpers do not authenticate a Home session or certify those older binaries as full suite rollback releases. 3ed-lineage runtime/session rollback still needs the combined admission review; these probes establish data/allocator compatibility only. |
| + |
| +Initial exact-candidate proof at07099a9 is recorded in /tmp/oficina-principal-compat-run1/verified.json. Final candidate is rerun after changes and recorded in the branch discussion. Full repo gate passed at the initial implementation; final checks are recorded separately rather than inferred. Two overlays fail the intended tests: substituting a nonnil empty verified address for absence, and removing the people ID-immutability trigger. This slice does not yet change Actor/grant queries, so it does not claim a malformed-empty-grant redemption integration test. |
| + |
| +Before mounting: combine with authoritative roster admission and the typed Actor/grant/addressOf changes from the design; preserve existing-account linking, production identity keys/content encoding and original person IDs. Run the named runtime rollback and platform-fenced restore drill. No live database census, content activation, secrets rotation, canary or deployment was performed by this branch. |
diff --git a/implementation/team-mode-integration.md b/implementation/team-mode-integration.md| new file mode 100644 |
| index 0000000..6b00e3c |
| --- /dev/null |
| +++ b/implementation/team-mode-integration.md |
| @@ -0,0 +1,97 @@ |
| +# Docs TeamMode integration |
| + |
| +Status: proposed implementation design, 2026-09-08. Source base is fetched Docs main `5fe0447ff896d9cc9083b326642bfac549ce524d`. This branch changes no runtime code, schema, configuration or deployment. Initial revision `e065b88` received explicit Claude Fable design APPROVE with no blockers. This clarification revision requires focused Fable review before implementation. |
| + |
| +## Dependencies and boundaries |
| + |
| +Use the approved principal design `a5a791819e65f573a8c329b8cb523771d659c975` and unmounted implementation `10a8364`, and the approved roster contract `4e7b3ffd915b76b623aec3f70f672ff6baf08164`. The canonical roster module is `amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5`; its exact code, full gate, actual CI and Fable approval are in Home discussion 31. Producer storage is separately owned and reviewed in Home discussion 33. Import the canonical module; do not copy its protocol, signing, clocks, generation rules, cache or provisioning verifier into Docs. |
| + |
| +Those contracts remain binding. In particular, a principal is identity only, roster standing expires within the measured 60-second lease ceiling, and an individual document grant is independent of team membership. The credential issuer sees no address/content; Docs receives no Home pepper. This design adds no opaque-subject grant kind, account merge, team-wide document visibility, operator bootstrap or production restore mechanism. |
| + |
| +Current main has existing-account Home linking, not authoritative TeamMode. Its Home resolver loads `people.email` and rejects absence. Ordinary Home completion resolves only `docs_identity_links`. `Actor.Subject` means a grant address; `handlers.member` still calls the email allowlist. `readable` and `writable` always include OwnerID. `shareGet` checks ownership without membership. `ApplyUpdate` checks access before CRDT replay, writes an optional snapshot before the update, commits the update later and bumps `updated_at` outside that transaction. `updatesGet` reads snapshot/tail rows after a single document check, and SSE checks only at connection establishment. These exact paths are integration work, not permission already supplied by the shared cache. |
| + |
| +## Configuration, migrations and durable cache |
| + |
| +Add `Config.Team *TeamConfig` beside existing `Config.Home`. Nil Home/nil Team preserves standalone behavior. Non-nil Home/nil Team preserves the explicitly limited linked-account preview only on databases never activated for TeamMode. Non-nil Team requires complete Home configuration and a verified operator record; it cannot silently fall back to either mode. |
| + |
| +`TeamConfig` contains `TeamID`, `DirectoryOrigin`, `ProvisioningBase64`, `OperatorKeysBase64` and `RosterToken`. Decode strictly with bounded canonical encodings. Operator keys are separate from browser assertion keys. Build exact expected `roster.Bindings` from Home issuer and consumer ID, app slug `docs`, configured team and directory origin, and Docs Origin. Pass these pins to `roster.VerifyProvisioning`; no browser path/query/header supplies them. Runtime delivery uses reviewed immutable platform configuration, never a public mutation endpoint. Neither a valid assertion nor the first visitor supplies a generation or owner. |
| + |
| +Keep `BootSchema` for the existing standalone/preview set. Add `TeamBootSchema`, merging the existing set with `principalstore.Schema` and a small namespaced additive Team state schema in that order. Apply the extended set only when TeamMode is explicitly configured. Once applied, keep it permanently. Principal census/range/FK failure must roll back the principal migration without renumbering anyone. Existing keys, content encoding, update/snapshot bytes, owner IDs, document IDs and grants remain unchanged. |
| + |
| +Team state has a singleton mode-required/bindings fingerprint row and a singleton opaque roster-evidence BLOB. The first marker is written only after configuration and the operator record verify; it pins issuer/directory/consumer/team/app/audience. It is not membership or provisioning authority. Before selecting a mode, every new binary checks whether this table/marker already exists: a present marker with missing/mismatched Team configuration refuses startup. A malformed or unreadable existing marker refuses too. This prevents an environment-variable omission from restoring email-allowlist standing. Commit this marker before any listener serves requests or Home-only allocation can occur. Add UPDATE/DELETE refusal triggers; binding changes require a separately reviewed migration, not a runtime rewrite. It cannot constrain older binaries that do not read it; the release floor below is mandatory. |
| + |
| +For initial activation, inspect existing `docs_identity_links` and `docs_home_identities`: all stored issuers must match the configured credential issuer or startup refuses for investigation. Existing Home sessions contain method/subject/AuthTime but no issuer field; preview sessions were minted only after resolving such a link. This census, the immutable binding marker and exact per-request lookup prevent reinterpretation under a different issuer. The actor never takes issuer from a cookie or unsigned browser field. |
| + |
| +Implement `roster.Durable` over the app's replicated SQLite writer, one bounded atomic BLOB replacement and one bounded load. The module already seals the payload under exact bindings and stores no reusable freshness timestamp; the adapter must neither decode it nor add its own age. Use the dedicated Home storage sealer under the existing Docs namespace, never the content key. Missing evidence returns `roster.ErrNoEvidence`; all other errors are generic. Honor contexts, the module's byte cap and atomic replace-on-success semantics. On restart `roster.New` verifies durable floors but begins Unknown. A failed fetch leaves age untouched. Token/key values and plaintext evidence never enter SQL statements, logs or metrics. |
| + |
| +There is no background cron: request/stream demand drives the canonical cache. No route updates operator records. Later reviewed configuration reload can call `UpdateRecord`; until then deploy/restart delivers a new record, with durable sequence/generation checks intact. A generation fence intentionally cannot be cleared by another record for the old generation. The 1024-record receipt bound is an operational lifetime ceiling; there is no silent history reset. Total durable-state loss still requires delivery of the latest audited operator record. |
| + |
| +## Required narrow shared-cache amendment |
| + |
| +The approved module currently holds `Cache.mu` through `Durable.Save`. `Lease.Valid` and `Remaining` take that same mutex. With SQLite evidence storage, a real deadlock exists: an edit holds the SQL writer and waits for the lease mutex, while a publishing refresh holds the mutex and waits for the SQL writer. A context-honoring Save breaks this cycle at FetchTimeout, but causes a repeated whole-writer stall; an unbounded/non-context-honoring wait can make it permanent. Calling the lease helper “local-only” does not remove this ordering. |
| + |
| +Amend the shared module in a separate reviewed revision before Docs integration. `Lease.Valid` uses `mu.TryLock`; contention returns false immediately. `Remaining` uses the same rule and returns zero on contention. After successful acquisition their existing version/clock/deadline checks remain unchanged. The immutable lease carries no new authority. `Check`, `Refresh` and `UpdateRecord` remain outside Docs write transactions. A contended member-only edit returns the existing retryable 503 path with no committed update; the editor retains its queued bytes. An exact independent writer grant may still authorize through the transaction's grant query. A stream closes/reconnects if it cannot obtain a usable new lease. No age extension or fabricated absence is introduced. |
| + |
| +Releasing `Cache.mu` around persistence would require a separate serial persistence protocol so an old flight cannot overwrite a newer record/floor, then compare/fence/publish coordination. That is a larger change to the reviewed atomic-cache contract and is not this proposal. The bounded TryLock refusal favors correctness; tests and the canary must measure whether retries become frequent. Do not solve the inversion with an unreplicated sidecar or a second live SQLite writer process. |
| + |
| +The deterministic regression test holds Docs' real writer transaction, starts a refresh whose Durable.Save waits for it, then calls both lease methods in the transaction. They must return immediately rather than deadlock. Roll back the edit, let publication complete, and prove a new decision permits the retry. A mutation restoring blocking Lock must fail on the intended bounded wait. Existing module tests, actual CI and explicit Fable code review are required for the amended canonical version before Docs pins it; `667dc82` remains unchanged meanwhile. Include an explicit unknown-kid map lookup/refusal before calling the canonical signature verifier and an unknown-kid test in that same amendment, closing the prior nonblocking review advisory without changing the wire. |
| + |
| +## Identity and ordinary Home completion |
| + |
| +Change the internal `Actor` to `{PersonID int64; VerifiedAddress *string}`. A local actor has the canonical current verified address; a Home-only actor has nil. Never convert nil to an empty string for normalization, allowlist lookup, grant equality, sample creation or display. Existing standalone and preview actors retain the same IDs and address behavior through adapted constructors. Keep the original authenticated `sessions.Session` and a separate optional Home subject in request identity; the grant address never becomes the Home subject. |
| + |
| +Construct `principalstore.Store` with exact configured issuer/team and `RequireMembership` backed by `roster.Check`. Member succeeds only with a currently valid lease. Fresh complete absence maps to `principalstore.ErrRefused`; Unknown or contended/expired lease maps to `ErrUnavailable`. The callback runs before the allocation SQL transaction. Principal allocation stores no standing and creates no documents, samples or grants. |
| + |
| +In TeamMode, the Home branch of identity resolution calls `principalstore.Resolve` and uses its optional-address result directly, without loading `people` for Home-only IDs. Resolution is read-only on ordinary requests: a missing identity never allocates from a session. Local session resolution accepts only the actual magiclink/keymail methods, resolves the verified address through the existing people path and disables automatic sample seeding in TeamMode. Standalone/preview retain their current opt-in sample behavior. A linked local principal resolves its Home subject by the exact issuer/person reverse lookup on `docs_identity_links` (already unique on that pair); no link means no roster standing, regardless of the delivery allowlist. |
| + |
| +The shared handoff still verifies/consumes the assertion before ordinary completion. Keep the fresh-link branch of `homeBridge.complete` distinct and unchanged in authority. For ordinary TeamMode completion, first resolve an existing principal. If an actual current browser session exists, resolve it read-only and require the same principal; refuse unknown/conflicting arrivals before allocating anything. For an unknown incoming identity with no current session, call `EnsureHome` only after the exact fresh roster callback succeeds. No address matching, EnsurePerson fallback or account merge exists. A concurrent local-link winner causes refusal/retry, not an invented principal. |
| + |
| +An existing resolved identity may receive an identity session without deriving document standing from it; its redirected request still undergoes roster/grant authorization. This permits a known linked guest to use an independent grant when team standing is absent. A new unknown Home identity cannot use an email grant because Home has supplied no email. Preserve the original assertion AuthTime, opaque subject, actual session-cookie duplicate checks and existing account-switch conflict checks. Allocation followed by failed session mint leaves one stable principal and requires a fresh handoff; replay is not revived. |
| + |
| +`existingPerson` is no longer a general principal resolver: keep a strict local-person helper for dual-auth link binding and add a typed existing-principal helper for ordinary completion/current-session comparison. Home-only identity must not accidentally satisfy fresh local authentication. A Home-only principal cannot later be silently attached to another local account: the approved cross-table triggers refuse it. The local sign-in/link option stays available before first Home-only creation, and rollout pilots must link existing accounts before testing new-person entry. This stage does not promise account merging. |
| + |
| +## One permission scope, two independent sources of access |
| + |
| +Introduce an unexported request access value binding the resolved actor, the exact Home subject (if known), TeamMode and a canonical roster decision. Construct it after session and identity middleware, before document routes. Call the cache outside SQL transactions. Missing TeamMode access context is Unknown/refused, never standalone fallback. Mutation methods also verify their supplied owner/actor matches this context; possession of a `Document` pointer or arbitrary numeric owner is not authorization. |
| + |
| +Use one permission-scope constructor in `grants.go`, parameterized by read/write/owner-administration. All GetFor/ListFor/SearchFor/CanWrite/update queries go through it, including `getDocumentForUpdates`, raw updates, `?since=`, exports and thumbnails reached from lists. Owner access is eligible only for standalone/preview or a currently valid TeamMode member lease. A grant clause is eligible only with a present verified address; writer access additionally requires RoleWriter. Keep `owner_id > 0` and existing nondeleted checks. Nil address emits no grant equality clause at all, so malformed historical empty-address grants cannot be redeemed. |
| + |
| +NotMember or Unknown means grant-only query scope: disable the OwnerID alternative even for the actual stored owner. Do not use “has any grant” as a global gate followed by the old full scope. Query the requested document's grant, with the actor's current verified address. Membership never opens another owner's private document, and a roster owner is not every document's owner. Store IDs and grants do not change when standing changes. |
| + |
| +Owner administration is distinct from writer access. GET share settings/ListGrants/IsPublished and POST Grant/Revoke/Publish/Unpublish require both a member lease and exact document ownership. Home-only owners can grant to a valid address; replace `addressOf` with the principal store's optional-address lookup and compare for self-grant only when present. A writer grant never grants administration. `CanShare` must include member standing as well as OwnerID, closing the current display/backend mismatch. Create requires a member lease and obtains its owner exclusively from the resolved actor. |
| + |
| +For an item that the scope cannot reach, return existing non-disclosing 404 for known nonmembership/ordinary lack of permission; return retryable 503 for Unknown where standing could not be evaluated. Database errors are 503 in TeamMode, never interpreted as absent membership or bad update bytes. Grant-backed item reads/writes succeed independently despite Unknown standing. Member-only collection mutations return 403 for definite nonmembership and 503 for Unknown. A list/search under Unknown may show only individually granted results and a catalog-backed temporary-status indicator; with no applicable grants it answers 503 instead of a misleading empty authoritative list. Known nonmembers may see the grant-only list, including empty. No response reveals whether an inaccessible document exists. Test nonexistent and existing-inaccessible sqids as pairs in every standing state, especially identical 503 bodies/status under Unknown and identical 404 under known nonmembership. |
| + |
| +## Mounting and local linking |
| + |
| +Preserve app-wide exact-origin/CSRF protection and the single shared assertion callback exception. Public sign-in, callback/finish, signed-out, static and existing public-capability routes stay outside document standing middleware. Public `/p/{token}` keeps its independent hash/token checks and does not gain mutation authority. |
| + |
| +Split the current signed-in group. Account-link routes use real session resolution plus existing local delivery/admission policy and strict local freshness; they do not require roster membership. This is necessary for a still-unlinked local account to establish its Home mapping. Home-only sessions route through local reauthentication and cannot create local proof on their own. Keep fixed sealed navigation continuation, mandatory link-init and confirm POSTs, bound pending-cookie/session checks and actual session rotation behavior. This account group exposes no document list, content, share administration or general mutation route. Existing signed-out behavior and deep-link safe return remain intact. |
| + |
| +The document group uses real session -> typed principal -> TeamMode access decision. It replaces `requireAdmission(list)` for TeamMode only. The email allowlist and existing HasGrant checks continue to gate local mail delivery and local authentication; they never confer team standing. In explicitly configured TeamMode, an empty AllowedAddresses value constructs an empty nonnil configured-address set (also in development), so nobody is implicitly allowed; actual valid grants can still admit local guests. This is a deliberate exception to standalone/preview production startup refusal for an empty list. A fresh Home-only team must not invent a seed email. Keep existing production mailer and instance-key requirements so local guest delivery remains supported; no logged-mail production fallback is introduced. `handlers.member` becomes presentation-only over the typed decision; backend mutations call the explicit authorization helpers and propagate Unknown separately instead of collapsing it to false. No whole-suite routing or gateway topology is changed here. |
| + |
| +## Operation boundaries and streams |
| + |
| +A middleware-time check is insufficient. GetFor/ListFor/SearchFor select through the permission scope, and late response emission revalidates access after expensive replay/render/export work. The renderer already buffers templates; attach a bounded response guard to its final write, JSON search output, exports and update-log chunks. Bind the guard to the exact selected document IDs; a member lease that expires cannot silently allow the old list of owned titles to be emitted as a guest response. Before first bytes, failure returns 503/404 as above. After response headers/body have begun, abort that HTTP response using `http.ErrAbortHandler`, preserving its semantics through middleware. Never append an error body or return a clean truncated success. A single Write is not transport-atomic. Bound writes to chunks (64 KiB initially) and check access before each; set exact Content-Length when the complete buffered length is known and bound blocking writes by the remaining member lease. This is a response-boundary rule, not a promise to retract bytes already sent. |
| + |
| +This is especially important for updatesGet: it sets Docs-Seq to the full buffered snapshot/tail before writing chunks. `fixtures/editor-entry.js` awaits `arrayBuffer()` before returning bytes/seq, and `fixtures/live-updates.mjs` advances its cursor only after fetch and application succeed; its catch leaves the cursor unchanged for the next event/reconnect. Test actual Chromium and WebKit against a real mid-body HTTP abort, including snapshot, raw and since paths: arrayBuffer must reject, no partial batch/cursor may be persisted or applied as a complete result, and the next authorized notification/reconnect must fetch from the unchanged cursor. Do not count a stub Promise rejection as that browser proof. If framework/proxy behavior turns the abort into clean EOF, activation is blocked until the actual transport is corrected. The server must stop content even when the browser cannot retry because access was permanently revoked. |
| + |
| +Before opening a mutation transaction, refresh standing outside SQL if needed. Inside the writer transaction, re-evaluate the exact writable grant or ownership predicate and use only nonblocking lease validation. Check again immediately before Commit. No network or Cache.Check occurs under the writer. The authorization point is the final pre-commit check; bytes already accepted before a concurrent later roster change cannot be retroactively undone. Contention, expired lease or storage uncertainty produces a retryable rollback. |
| + |
| +`ApplyUpdate` must recheck after body decoding and CRDT replay, then authorize inside the same writer transaction that appends the update. Move its `documents.updated_at` bump into that transaction under the actor scope; do not retain the current post-commit owner-of-document shortcut. Snapshot creation/pruning triggered by ApplyUpdate is a separate write transaction today: thread the same authorization guard through it and check before its commit too. A lost-authority/Unknown result is not swallowed as an ordinary optional snapshot failure; stop the user mutation and preserve retryable semantics. Pure replay/reseeding of an in-memory CRDT handle does not create durable authority. Preserve snapshot correctness, encryption and gapless sequence guarantees. |
| + |
| +Create, Grant, Revoke, Publish and Unpublish likewise put the final owner/member predicate and mutation inside one writer transaction with the final lease check. Existing standalone tests must retain their semantics. For Unknown/expired/contended authority or grant-query storage failure, updatesPost returns 503 before `updateIsClientFault` classification, never broadcasts or returns Docs-Seq, and the browser save queue retains the batch. For definite NotMember with no writer grant, return the existing non-disclosing 404; that is permanent revocation and the current queue drops/reports the refused batch. A retained valid writer grant succeeds regardless of roster absence. Test these cases separately; do not label a definite removal as an indefinitely retryable outage. A committed update keeps its existing idempotent retry behavior for unrelated response failures. |
| + |
| +SSE re-resolves the actual session and access for that exact document before the initial event, every update event and every heartbeat. This includes signout/session revocation, grant removal and current roster age. Use a timer at the current member lease deadline; at expiry obtain a new decision outside SQL and either re-arm from that new lease or close. A failed refresh cannot keep the old stream open. If the specific document remains individually granted, the stream can continue as a guest with grant/session rechecks before each emission. No standing timer is manufactured for a grant, and the existing bounded heartbeat/connection TTL remains. Silent streams with member-only access close by lease expiry. Test immediate per-event generation fencing and grant removal as well as timer expiry; do not infer them from connect-time tests. |
| + |
| +## Release floor and acceptance |
| + |
| +The principal store's old/new/old/new proof establishes schema/ID/content compatibility. It does not establish authorization-safe rollback after TeamMode activation. In particular, `5fe0447` or its `3ed7665` lineage can grant an allowlisted removed local person OwnerID access without a roster. The earlier principal design's rollback paragraph must be read with this narrower limit. Once TeamMode serves users, rollback must use a reviewed TeamMode-aware binary with the same mandatory-mode marker and standing/grant rules. Otherwise supported maintenance must keep document traffic closed. Clearing Team configuration, deleting markers or revoking only Home sessions is not a safe rollback procedure. This design authorizes no raw production DB edits. |
| + |
| +Implement in bounded reviewed branches: (1) the shared nonblocking-lease amendment and tests; (2) Docs typed identity/principal/schema/durable Team runtime plus callback/account-group integration; (3) unified permission scopes, late write/read guards and stream behavior; (4) full actual-router/browser/rollback canary with immutable configuration. No TeamMode activation between identity allocation and the complete permission stage. Source can remain unmounted until all pieces are combined. Every code branch requires the repo gate, meaningful mutations, actual exact-SHA CI and explicit Fable review. |
| + |
| +Acceptance uses two local people with unchanged owned/shared/private documents, two Home-only subjects, two separately pinned teams and actual captured-mail local authentication plus real signed handoffs. Cover existing-link ID continuity, optional nil address and malformed empty grants, unknown principal refusal on every roster failure, explicit first document creation, Home-only owner sharing to an address, current-session conflict before allocation, issuer/config/mode-marker mismatch and missing Team config, unchanged AuthTime and content bytes/encoding. Keep local linking reachable while unlinked and prove it grants no document standing before a verified roster decision. |
| + |
| +Exercise every read/write/export/search/share/update/raw/since route under Member, NotMember and Unknown. A former owner with one unrelated grant must reach only that grant's document. Expire a lease during body read, replay, transaction acquisition, final response rendering and silent SSE; verify no unauthorized update/snapshot/admin commit or late content emission. Make roster refresh wait on the actual SQLite writer to prove the TryLock amendment and retained edit retry. Revoke an individual writer grant while a POST waits for the writer, then prove the transaction's grant predicate refuses it. Mutations remove nil-address checks, OwnerID exclusion, the final write guard, share GET standing, stream timer and Unknown retry classification; each must fail for its intended behavior. |
| + |
| +Before activation, run current ID/FK census, exact TeamMode-aware binary rollback and restored-copy drills with preserved keys/content. Record live canonical X-Carlos-Version and bounded revocation observations on two isolated team instances. The initial contention canary is two active editors posting twice per second for 120 seconds against a healthy directory: zero deadlocks/fetch timeouts/lease-expiry outages, fewer than 1% retryable contention responses, and every retained batch committed within 10 seconds after load stops. These are initial engineering acceptance thresholds, not a production SLA; changing them needs a recorded review before activation. The all-refresh-failure canary must still stop member-only operations by the 60-second lease ceiling. Production restore still depends on supported exclusive maintenance, session/handoff replay reset and owner-confirmed directory generation; platform discussion 117 and operator-delivery gates remain external prerequisites. A synthetic signed roster or a passing local migration never substitutes for them. |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index b96c866..26378fe 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -29,11 +29,13 @@ import ( |
| "github.com/go-chi/chi/v5" |
| "github.com/carlosframework/docs/internal/core" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| ) |
| // Config configures New. |
| type Config struct { |
| Home *HomeConfig |
| + Team *TeamConfig |
| // DBPath is the SQLite file New opens via rastrillo's db package. |
| DBPath string |
| @@ -351,6 +353,10 @@ func New(cfg Config) (*App, error) { |
| if err != nil { |
| return nil, err |
| } |
| + teamSettings, err := validatedTeamConfig(cfg.Team, homeSettings, cfg.Origin) |
| + if err != nil { |
| + return nil, err |
| + } |
| instanceKey, err := resolveInstanceKey(cfg) |
| if err != nil { |
| return nil, err |
| @@ -379,7 +385,16 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: open database: %w", err) |
| } |
| - if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| + required, activated, err := teamstate.Required(context.Background(), d.Writer()) |
| + if err != nil || activated && (teamSettings == nil || required != teamSettings.fingerprint) { |
| + d.Close() |
| + return nil, errTeamConfig |
| + } |
| + schema := BootSchema |
| + if teamSettings != nil { |
| + schema = TeamBootSchema |
| + } |
| + if _, err := migrate.Apply(context.Background(), d, schema); err != nil { |
| d.Close() |
| return nil, fmt.Errorf("docs: apply migrations: %w", err) |
| } |
| @@ -417,6 +432,14 @@ func New(cfg Config) (*App, error) { |
| // The sample library is for members. A guest admitted by a grant is |
| // visiting, not moving in. |
| store.seedOnlyMembers = list.configured |
| + if teamSettings != nil { |
| + store.team, err = newTeamRuntime(context.Background(), teamSettings, d.Writer(), homeSettings) |
| + if err != nil { |
| + rt.Close(context.Background()) |
| + d.Close() |
| + return nil, err |
| + } |
| + } |
| list.granted = func(address string) (bool, error) { |
| // A short deadline rather than context.Background(): this runs on |
| @@ -464,6 +487,17 @@ func New(cfg Config) (*App, error) { |
| return nil, err |
| } |
| h.homeEnabled = true |
| + if store.team != nil { |
| + store.team.bridge = home.bridge |
| + home.bridge.resolver.team = store.team |
| + store.team.readSession, err = newTeamSessionReader(d.Writer(), cfg.Origin, home.bridge.sessionCookie, logger) |
| + if err != nil { |
| + rt.Close(context.Background()) |
| + d.Close() |
| + return nil, err |
| + } |
| + } |
| + |
| } |
| r := chi.NewRouter() |
| // Mounted above every route, per rastrillo SKILL.md §5: refuses a |
| @@ -515,6 +549,15 @@ func New(cfg Config) (*App, error) { |
| // SKILL.md §5/§1's "signed-in routes... in a Group with |
| // sess.Require", here auth's own RequireSession. A signed-out |
| // GET/HEAD redirects to /signin; anything else 403s. |
| + |
| + if store.team != nil { |
| + r.Group(func(r chi.Router) { |
| + r.Use(home.bridge.sessions.Require) |
| + r.Use(home.bridge.resolver.middleware("/signin/local")) |
| + r.Use(requireTeamAccountAdmission(list)) |
| + home.mountAccount(r) |
| + }) |
| + } |
| r.Group(func(r chi.Router) { |
| if home == nil { |
| r.Use(authPlugin.RequireSession) |
| @@ -525,9 +568,13 @@ func New(cfg Config) (*App, error) { |
| // Admission is re-asked per request, not only at sign-in, so |
| // revoking a guest's last grant ends their access now rather |
| // than whenever their 30-day session happens to expire. |
| - r.Use(requireAdmission(list, "/signin")) |
| - if home != nil { |
| - home.mountAccount(r) |
| + if store.team != nil { |
| + r.Use(store.team.admission) |
| + } else { |
| + r.Use(requireAdmission(list, "/signin")) |
| + if home != nil { |
| + home.mountAccount(r) |
| + } |
| } |
| r.Get("/", h.list) |
| r.Post("/documents", h.create) |
diff --git a/internal/docs/auth.go b/internal/docs/auth.go| index 376dc04..a9bf757 100644 |
| --- a/internal/docs/auth.go |
| +++ b/internal/docs/auth.go |
| @@ -281,7 +281,7 @@ func resolveAllowlist(cfg Config) (allowlist, error) { |
| } |
| addrs[addr] = struct{}{} |
| } |
| - if len(addrs) > 0 { |
| + if len(addrs) > 0 || cfg.Team != nil { |
| return allowlist{addrs: addrs}, nil |
| } |
| if cfg.Env == "development" { |
| @@ -394,7 +394,10 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.Ha |
| func requireAdmission(list allowlist, signinPath string) func(http.Handler) http.Handler { |
| admission := requireAdmissionOf(list, signinPath, func(r *http.Request) (string, bool) { |
| if id, ok := currentIdentity(r); ok { |
| - return id.Actor.Subject, true |
| + if id.Actor.VerifiedAddress == nil { |
| + return "", false |
| + } |
| + return *id.Actor.VerifiedAddress, true |
| } |
| s, ok := sessions.Current(r) |
| return s.Subject, ok && s.Method != homeAssertionMethod |
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go| index 17b081a..66d09c5 100644 |
| --- a/internal/docs/content_runtime_test.go |
| +++ b/internal/docs/content_runtime_test.go |
| @@ -133,7 +133,7 @@ func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) { |
| if err := convertContent(ctx, app.store.db, cipher); err != nil { |
| t.Fatal(err) |
| } |
| - if seq, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| + if seq, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err) |
| } |
| update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| @@ -287,7 +287,7 @@ func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); err != nil { |
| + if _, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); err != nil { |
| t.Fatal(err) |
| } |
| h, seq, _, err := app.store.replay(ctx, doc) |
diff --git a/internal/docs/export.go b/internal/docs/export.go| index 82fd838..5f5ddd7 100644 |
| --- a/internal/docs/export.go |
| +++ b/internal/docs/export.go |
| @@ -223,7 +223,7 @@ func (h *handlers) exportMarkdown(w http.ResponseWriter, r *http.Request) { |
| // latin-1 turns every em dash into mojibake. |
| w.Header().Set("Content-Type", "text/markdown; charset=utf-8") |
| setDownloadName(w, doc, ".md") |
| - if _, err := w.Write([]byte(body)); err != nil { |
| + if err := writeGuardedBody(w, r, 0, []byte(body)); err != nil { |
| h.logger.Error("docs: write markdown export", "sqid", doc.Sqid, "err", err) |
| } |
| } |
| @@ -238,7 +238,7 @@ func (h *handlers) exportPDF(w http.ResponseWriter, r *http.Request) { |
| body, report, err := export.PDF(structure) |
| if err != nil { |
| h.logger.Error("docs: render pdf export", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| if !report.Empty() { |
| @@ -271,7 +271,7 @@ func (h *handlers) exportPDF(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/pdf") |
| setDownloadName(w, doc, ".pdf") |
| - if _, err := w.Write(body); err != nil { |
| + if err := writeGuardedBody(w, r, 0, body); err != nil { |
| h.logger.Error("docs: write pdf export", "sqid", doc.Sqid, "err", err) |
| } |
| } |
| @@ -288,14 +288,14 @@ func (h *handlers) documentForExport(w http.ResponseWriter, r *http.Request) (*D |
| structureJSON, err := h.store.JSON(r.Context(), doc) |
| if err != nil { |
| h.logger.Error("docs: document structure", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return nil, export.Document{}, false |
| } |
| structure, err := export.Parse(doc.Title, structureJSON) |
| if err != nil { |
| h.logger.Error("docs: parse document structure", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return nil, export.Document{}, false |
| } |
| return doc, structure, true |
diff --git a/internal/docs/grants.go b/internal/docs/grants.go| index 5574a77..a91518f 100644 |
| --- a/internal/docs/grants.go |
| +++ b/internal/docs/grants.go |
| @@ -1,6 +1,7 @@ |
| package docs |
| import ( |
| + "amadan.net/oficina/home/roster" |
| "context" |
| "errors" |
| "fmt" |
| @@ -67,8 +68,17 @@ var errEmptyAddress = errors.New("docs: cannot grant to an empty address") |
| // keyed on the id and grants are keyed on the address — see 0005_grants.sql |
| // for why grants are not keyed on the id. |
| type Actor struct { |
| - PersonID int64 |
| - Subject string |
| + PersonID int64 |
| + VerifiedAddress *string |
| +} |
| + |
| +// localActor keeps a verified local address distinct from an opaque Home subject. |
| +func localActor(id int64, address string) Actor { |
| + address = subjectOf(address) |
| + if address == "" { |
| + return Actor{PersonID: id} |
| + } |
| + return Actor{PersonID: id, VerifiedAddress: &address} |
| } |
| // writable scopes a query to the documents one person may change: the ones |
| @@ -76,15 +86,7 @@ type Actor struct { |
| // absent from this query on purpose, and that absence is the role's whole |
| // meaning. |
| func writable(g *gorm.DB, a Actor) *gorm.DB { |
| - if a.PersonID <= 0 { |
| - return g.Where("1 = 0") |
| - } |
| - return g.Where( |
| - "documents.owner_id > 0 AND (documents.owner_id = ? OR EXISTS ("+ |
| - "SELECT 1 FROM grants WHERE grants.doc_id = documents.id "+ |
| - "AND grants.subject = ? AND grants.role = ?))", |
| - a.PersonID, subjectOf(a.Subject), RoleWriter, |
| - ) |
| + return (&Store{}).permissionScope(context.Background(), g, a, permissionWrite) |
| } |
| // subjectOf normalises an address the way EnsurePerson stores one, so a grant |
| @@ -100,12 +102,20 @@ func subjectOf(address string) string { |
| // addressOf maps a person id back to the address they signed in with, so |
| // Grant can refuse a grant somebody makes to themselves. |
| -func (s *Store) addressOf(ctx context.Context, person int64) (string, error) { |
| +func (s *Store) addressOf(ctx context.Context, person int64) (*string, error) { |
| + if s.team != nil { |
| + p, err := s.team.principals.Person(ctx, person) |
| + return p.VerifiedAddress, err |
| + } |
| var p Person |
| if err := s.db.WithContext(ctx).First(&p, person).Error; err != nil { |
| - return "", err |
| + return nil, err |
| + } |
| + address := subjectOf(p.Email) |
| + if address == "" { |
| + return nil, errIdentityRefused |
| } |
| - return subjectOf(p.Email), nil |
| + return &address, nil |
| } |
| // looksLikeAnAddress is the cheapest check that keeps nonsense out of the |
| @@ -139,15 +149,8 @@ func looksLikeAnAddress(subject string) bool { |
| // it. 0002_owners.sql wrote owner_id 0 onto every pre-existing row, and three |
| // tests pin that 0 matches nothing; a grants clause must not become the way |
| // around them. |
| -func readable(g *gorm.DB, owner int64, subject string) *gorm.DB { |
| - if owner <= 0 { |
| - return g.Where("1 = 0") |
| - } |
| - return g.Where( |
| - "documents.owner_id > 0 AND (documents.owner_id = ? OR EXISTS ("+ |
| - "SELECT 1 FROM grants WHERE grants.doc_id = documents.id AND grants.subject = ?))", |
| - owner, subjectOf(subject), |
| - ) |
| +func readable(g *gorm.DB, a Actor) *gorm.DB { |
| + return (&Store{}).permissionScope(context.Background(), g, a, permissionRead) |
| } |
| // Grant gives an address access to a document. Only the owner may call it: |
| @@ -181,7 +184,7 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro |
| if err != nil { |
| return fmt.Errorf("docs: resolve owner address: %w", err) |
| } |
| - if ownerAddress == subject { |
| + if ownerAddress != nil && *ownerAddress == subject { |
| return errEmptyAddress |
| } |
| @@ -192,10 +195,12 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro |
| CreatedBy: owner, |
| CreatedAt: time.Now().UTC(), |
| } |
| - return s.db.WithContext(ctx).Clauses(clause.OnConflict{ |
| - Columns: []clause.Column{{Name: "doc_id"}, {Name: "subject"}}, |
| - DoUpdates: clause.AssignmentColumns([]string{"role", "created_by"}), |
| - }).Create(&grant).Error |
| + return s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error { |
| + return tx.Clauses(clause.OnConflict{ |
| + Columns: []clause.Column{{Name: "doc_id"}, {Name: "subject"}}, |
| + DoUpdates: clause.AssignmentColumns([]string{"role", "created_by"}), |
| + }).Create(&grant).Error |
| + }) |
| } |
| // HasGrant reports whether an address holds a grant on any document here. |
| @@ -265,9 +270,11 @@ func (s *Store) Revoke(ctx context.Context, owner int64, sqid, address string) e |
| if err != nil { |
| return err |
| } |
| - return s.db.WithContext(ctx). |
| - Where("doc_id = ? AND subject = ?", doc.ID, subjectOf(address)). |
| - Delete(&Grant{}).Error |
| + return s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error { |
| + return tx. |
| + Where("doc_id = ? AND subject = ?", doc.ID, subjectOf(address)). |
| + Delete(&Grant{}).Error |
| + }) |
| } |
| // GetFor fetches a document the acting person may read: theirs, or one they |
| @@ -283,12 +290,13 @@ func (s *Store) Revoke(ctx context.Context, owner int64, sqid, address string) e |
| // as one that does not exist, and the handler turns both into 404 (§4.2). |
| func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, error) { |
| var doc Document |
| - err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject). |
| + err := s.permissionScope(ctx, s.db.WithContext(ctx), a, permissionRead). |
| Where("documents.sqid = ? AND documents.deleted_at IS NULL", sqid). |
| First(&doc).Error |
| if err != nil { |
| - return nil, err |
| + return nil, s.permissionError(ctx, a, err) |
| } |
| + s.rememberResponse(ctx, permissionRead, &doc) |
| return &doc, nil |
| } |
| @@ -305,7 +313,7 @@ func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, er |
| // may not costs them their work. |
| func (s *Store) CanWrite(ctx context.Context, a Actor, docID int64) bool { |
| var count int64 |
| - if err := writable(s.db.WithContext(ctx).Model(&Document{}), a). |
| + if err := s.permissionScope(ctx, s.db.WithContext(ctx).Model(&Document{}), a, permissionWrite). |
| Where("documents.id = ?", docID). |
| Count(&count).Error; err != nil { |
| s.logger.Error("docs: checking write access", "doc", docID, "err", err) |
| @@ -330,12 +338,19 @@ func (s *Store) CanWrite(ctx context.Context, a Actor, docID int64) bool { |
| // docRow.SharedNote, without reordering the list. |
| func (s *Store) ListFor(ctx context.Context, a Actor) ([]*Document, error) { |
| var docs []*Document |
| - err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject). |
| + err := s.permissionScope(ctx, s.db.WithContext(ctx), a, permissionRead). |
| Where("documents.deleted_at IS NULL"). |
| Order("documents.updated_at DESC, documents.id DESC"). |
| Find(&docs).Error |
| if err != nil { |
| - return nil, fmt.Errorf("docs: list readable documents: %w", err) |
| + return nil, s.permissionError(ctx, a, fmt.Errorf("docs: list readable documents: %w", err)) |
| + } |
| + if s.team != nil && len(docs) == 0 { |
| + a, ok := s.access(ctx, a) |
| + if !ok || a.state() == roster.Unknown { |
| + return nil, errTeamUnavailable |
| + } |
| } |
| + s.rememberResponse(ctx, permissionRead, docs...) |
| return docs, nil |
| } |
diff --git a/internal/docs/grants_test.go b/internal/docs/grants_test.go| index 09e817b..c4f9b33 100644 |
| --- a/internal/docs/grants_test.go |
| +++ b/internal/docs/grants_test.go |
| @@ -45,7 +45,7 @@ func TestReadableIncludesGrantees(t *testing.T) { |
| // Bob reads it because he was granted it. |
| var bobRows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&bobRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&bobRows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(bobRows) != 1 || bobRows[0].ID != doc.ID { |
| @@ -54,7 +54,7 @@ func TestReadableIncludesGrantees(t *testing.T) { |
| // Alice still reads her own. |
| var aliceRows []*Document |
| - if err := readable(store.db.WithContext(ctx), alice, "alice@example.com").Find(&aliceRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(alice, "alice@example.com")).Find(&aliceRows).Error; err != nil { |
| t.Fatalf("readable(alice): %v", err) |
| } |
| if len(aliceRows) != 1 { |
| @@ -64,7 +64,7 @@ func TestReadableIncludesGrantees(t *testing.T) { |
| // Carol was granted nothing and reads nothing. Without this the whole |
| // thing could be a query that returns every row. |
| var carolRows []*Document |
| - if err := readable(store.db.WithContext(ctx), carol, "carol@example.com").Find(&carolRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(carol, "carol@example.com")).Find(&carolRows).Error; err != nil { |
| t.Fatalf("readable(carol): %v", err) |
| } |
| if len(carolRows) != 0 { |
| @@ -111,7 +111,7 @@ func TestGrantToSomebodyWhoHasNeverSignedIn(t *testing.T) { |
| t.Fatalf("EnsurePerson(dave): %v", err) |
| } |
| var daveRows []*Document |
| - if err := readable(store.db.WithContext(ctx), dave, "dave@example.com").Find(&daveRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(dave, "dave@example.com")).Find(&daveRows).Error; err != nil { |
| t.Fatalf("readable(dave): %v", err) |
| } |
| if len(daveRows) != 1 { |
| @@ -141,7 +141,7 @@ func TestGrantSubjectIsNormalised(t *testing.T) { |
| } |
| var rows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(rows) != 1 { |
| @@ -178,7 +178,7 @@ func TestReadableKeepsOrphansInvisible(t *testing.T) { |
| } |
| var rows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(rows) != 0 { |
diff --git a/internal/docs/grants_write_test.go b/internal/docs/grants_write_test.go| index 579636e..fab13b3 100644 |
| --- a/internal/docs/grants_write_test.go |
| +++ b/internal/docs/grants_write_test.go |
| @@ -40,7 +40,7 @@ func TestApplyUpdateRefusesAReader(t *testing.T) { |
| // Bob can read it — that is what the grant is for. |
| var rows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(rows) != 1 { |
| @@ -48,7 +48,7 @@ func TestApplyUpdateRefusesAReader(t *testing.T) { |
| } |
| // And he cannot write it. |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: bob, Subject: "bob@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(bob, "bob@example.com"), []byte{0}) |
| if !errors.Is(err, ErrNotWritable) { |
| t.Errorf("ApplyUpdate as a reader: got %v, want ErrNotWritable", err) |
| } |
| @@ -77,7 +77,7 @@ func TestApplyUpdateAllowsAWriter(t *testing.T) { |
| // A malformed CRDT update is fine here: it must get *past* authorization |
| // and fail on its own merits, which is what distinguishes "refused" from |
| // "not a valid update". |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: bob, Subject: "bob@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(bob, "bob@example.com"), []byte{0}) |
| if errors.Is(err, ErrNotWritable) { |
| t.Errorf("ApplyUpdate as a writer was refused authorization; the grant's role is doing nothing") |
| } |
| @@ -98,7 +98,7 @@ func TestApplyUpdateAllowsTheOwner(t *testing.T) { |
| t.Fatalf("Create: %v", err) |
| } |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: alice, Subject: "alice@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(alice, "alice@example.com"), []byte{0}) |
| if errors.Is(err, ErrNotWritable) { |
| t.Errorf("the owner was refused a write to her own document: %v", err) |
| } |
| @@ -121,7 +121,7 @@ func TestApplyUpdateRefusesAStranger(t *testing.T) { |
| t.Fatalf("Create: %v", err) |
| } |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: carol, Subject: "carol@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(carol, "carol@example.com"), []byte{0}) |
| if !errors.Is(err, ErrNotWritable) { |
| t.Errorf("ApplyUpdate as a stranger: got %v, want ErrNotWritable", err) |
| } |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 4894145..5fd579c 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -1,6 +1,7 @@ |
| package docs |
| import ( |
| + "amadan.net/oficina/home/roster" |
| "errors" |
| "html/template" |
| "io" |
| @@ -111,7 +112,8 @@ type handlers struct { |
| // (rastrillo/auth.DefaultSessionTTL); a page with no way to end one |
| // on purpose is a real gap on something public, not a cosmetic one. |
| type listPage struct { |
| - HomeLinkLabel string |
| + PartialCatalog string |
| + HomeLinkLabel string |
| // CanCreate gates the create form in the list header. A guest is |
| // answered 403 by POST /documents, and a form whose only outcome is a |
| @@ -208,11 +210,15 @@ func (h *handlers) actor(r *http.Request) (Actor, error) { |
| // members — the guest was invited by one. Pretending POST /documents does |
| // not exist would be a lie told for no gain. |
| func (h *handlers) member(r *http.Request) bool { |
| + if h.store.team != nil { |
| + a, ok := r.Context().Value(accessContextKey{}).(*requestAccess) |
| + return ok && a != nil && a.runtime == h.store.team && a.state() == roster.Member |
| + } |
| id, err := requestActor(r, h.store) |
| if err != nil { |
| return false |
| } |
| - return h.isMember == nil || h.isMember(id.Subject) |
| + return id.VerifiedAddress != nil && (h.isMember == nil || h.isMember(*id.VerifiedAddress)) |
| } |
| // list serves GET /: the document list, spec §6.4 and Paul's four |
| @@ -242,7 +248,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| who, err := h.actor(r) |
| if err != nil { |
| h.logger.Error("docs: resolve signed-in person", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -254,6 +260,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| query := strings.TrimSpace(r.URL.Query().Get("q")) |
| data := listPage{ |
| + PartialCatalog: h.partialCatalog(r), |
| HomeLinkLabel: h.homeLinkLabel(r), |
| CanCreate: h.member(r), |
| Title: rastrillo.T(r, "docs.list.title"), |
| @@ -276,7 +283,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| hits, err := h.store.SearchFor(r.Context(), who, query) |
| if err != nil { |
| h.logger.Error("docs: search documents", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| now := time.Now().UTC() |
| @@ -293,7 +300,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| } |
| if err := h.render.page(w, r, "list", data); err != nil { |
| h.logger.Error("docs: render list", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| return |
| } |
| @@ -301,13 +308,13 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| docs, err := h.store.ListFor(r.Context(), who) |
| if err != nil { |
| h.logger.Error("docs: list documents", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()), who.PersonID) |
| if err := h.render.page(w, r, "list", data); err != nil { |
| h.logger.Error("docs: render list", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
| @@ -325,14 +332,13 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| // minting documents of their own is not that. Without this, one grant |
| // is a transitive key to the front door — the guest creates, shares, |
| // and admits the next person (branch review, finding 1). |
| - if !h.member(r) { |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + if !h.requireMemberRequest(w, r, false) { |
| return |
| } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve signed-in person", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -344,7 +350,7 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| doc, err := h.store.Create(r.Context(), owner, title) |
| if err != nil { |
| h.logger.Error("docs: create document", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther) |
| @@ -539,7 +545,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| actor, err := h.actor(r) |
| if err != nil { |
| h.logger.Error("docs: resolve signed-in person", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -552,14 +558,14 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| h.logger.Error("docs: get document", "sqid", sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| html, err := h.store.HTML(r.Context(), doc) |
| if err != nil { |
| h.logger.Error("docs: document html", "sqid", sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -570,7 +576,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| SignOutLabel: rastrillo.T(r, "docs.signout.label"), |
| BackLabel: rastrillo.T(r, "docs.list.title"), |
| Sqid: doc.Sqid, |
| - CanShare: doc.OwnerID == actor.PersonID, |
| + CanShare: doc.OwnerID == actor.PersonID && h.member(r), |
| CanWrite: h.store.CanWrite(r.Context(), actor, doc.ID), |
| ShareLabel: rastrillo.T(r, "docs.share.title"), |
| EditorScript: editorScriptPath(), |
| @@ -587,7 +593,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| } |
| if err := h.render.page(w, r, "show", data); err != nil { |
| h.logger.Error("docs: render show", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
| @@ -613,7 +619,7 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request) |
| actor, err := h.actor(r) |
| if err != nil { |
| h.logger.Error("docs: resolve signed-in person", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return nil, false |
| } |
| @@ -627,7 +633,7 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request) |
| return nil, false |
| } |
| h.logger.Error("docs: get document", "sqid", sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return nil, false |
| } |
| return doc, true |
| @@ -705,14 +711,14 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| Order("seq ASC"). |
| Find(&updates).Error; err != nil { |
| h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| for i := range updates { |
| if err := h.store.openContentUpdate(doc.GUID, &updates[i]); err != nil { |
| h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| } |
| @@ -726,7 +732,7 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| } |
| if total > maxLogBytes { |
| h.logger.Error("docs: update log exceeds maxLogBytes", "sqid", doc.Sqid, "bytes", total, "cap", maxLogBytes) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -750,21 +756,15 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/octet-stream") |
| w.Header().Set("Docs-Seq", strconv.FormatInt(highest, 10)) |
| + parts := make([][]byte, 0, len(updates)+1) |
| if snapshot != nil { |
| - if _, err := w.Write(snapshot.State); err != nil { |
| - // See below: the client went away mid-response. |
| - h.logger.Error("docs: write snapshot", "sqid", doc.Sqid, "err", err) |
| - return |
| - } |
| + parts = append(parts, snapshot.State) |
| } |
| for _, u := range updates { |
| - if _, err := w.Write(u.UpdateBin); err != nil { |
| - // The client went away mid-response; nothing left to do |
| - // but stop writing. Logged, not surfaced — the status |
| - // line is already on the wire. |
| - h.logger.Error("docs: write update log", "sqid", doc.Sqid, "err", err) |
| - return |
| - } |
| + parts = append(parts, u.UpdateBin) |
| + } |
| + if err := writeGuardedBody(w, r, 0, parts...); err != nil { |
| + h.logger.Error("docs: write update log", "err", err) |
| } |
| } |
| @@ -793,14 +793,14 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| h.logger.Error("docs: read posted update", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| actor, err := h.actor(r) |
| if err != nil { |
| h.logger.Error("docs: resolve actor for update", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -818,6 +818,10 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound) |
| return |
| } |
| + if errors.Is(err, errTeamUnavailable) { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| if updateIsClientFault(err) { |
| // Info, not Error: a client sending bytes the core |
| // cleanly rejects is an ordinary, expected outcome of |
| @@ -832,7 +836,7 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| h.logger.Error("docs: apply posted update", "sqid", doc.Sqid, "doc_id", doc.ID, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
diff --git a/internal/docs/home_exchange.go b/internal/docs/home_exchange.go| index 19a2f8f..ec0b796 100644 |
| --- a/internal/docs/home_exchange.go |
| +++ b/internal/docs/home_exchange.go |
| @@ -220,17 +220,23 @@ func (b *homeBridge) complete(w http.ResponseWriter, r *http.Request, claims ass |
| setHomeCookie(w, homeProofCookie, secret, 300) |
| return nil |
| } |
| - id, _, err := b.links.Resolve(r.Context(), claims.Issuer, claims.Subject) |
| - if errors.Is(err, linkstore.ErrRefused) { |
| - return rejectHome(r, "unknown", http.StatusForbidden) |
| - } |
| - if err != nil { |
| - return errHomeBridge |
| - } |
| - if result.session != nil { |
| - person, err := b.existingPerson(r, *result.session) |
| - if err != nil || person.ID != id { |
| - return rejectHome(r, "failed", http.StatusForbidden) |
| + if b.store.team != nil { |
| + if err := b.completeTeamPrincipal(r, claims, result.session); err != nil { |
| + return err |
| + } |
| + } else { |
| + id, _, err := b.links.Resolve(r.Context(), claims.Issuer, claims.Subject) |
| + if errors.Is(err, linkstore.ErrRefused) { |
| + return rejectHome(r, "unknown", http.StatusForbidden) |
| + } |
| + if err != nil { |
| + return errHomeBridge |
| + } |
| + if result.session != nil { |
| + person, err := b.existingPerson(r, *result.session) |
| + if err != nil || person.ID != id { |
| + return rejectHome(r, "failed", http.StatusForbidden) |
| + } |
| } |
| } |
| if err := b.sessions.SignIn(w, r, sessions.Session{Subject: claims.Subject, Method: homeAssertionMethod, AuthTime: time.Unix(claims.AuthTime, 0)}); err != nil { |
diff --git a/internal/docs/home_exchange_test.go b/internal/docs/home_exchange_test.go| index 8b491f4..49ad50b 100644 |
| --- a/internal/docs/home_exchange_test.go |
| +++ b/internal/docs/home_exchange_test.go |
| @@ -204,7 +204,7 @@ func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { |
| t.Fatalf("wrong Home session: %+v", session) |
| } |
| identity, err := f.b.resolver.resolve(sessions.WithSession(r, session)) |
| - if err != nil || identity.Actor.PersonID != person || identity.Actor.Subject != "one@example.com" { |
| + if err != nil || identity.Actor.PersonID != person || (identity.Actor.VerifiedAddress == nil || *identity.Actor.VerifiedAddress != "one@example.com") { |
| t.Fatalf("identity=%+v %v", identity, err) |
| } |
| if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil { |
diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go| index 59ec79f..a7bd9be 100644 |
| --- a/internal/docs/home_router_integration_test.go |
| +++ b/internal/docs/home_router_integration_test.go |
| @@ -70,7 +70,11 @@ func (b *routerBrowser) request(method, path, body string) *http.Request { |
| } |
| func (b *routerBrowser) serve(r *http.Request) *httptest.ResponseRecorder { |
| rec := httptest.NewRecorder() |
| - b.f.app.Handler().ServeHTTP(rec, r) |
| + var w http.ResponseWriter = rec |
| + if b.f.app.store.team != nil { |
| + w = &teamDeadlineRecorder{ResponseRecorder: rec} |
| + } |
| + b.f.app.Handler().ServeHTTP(w, r) |
| for _, c := range rec.Result().Cookies() { |
| if c.MaxAge < 0 { |
| delete(b.cookies, c.Name) |
| @@ -220,7 +224,7 @@ func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *tes |
| owner = bob |
| address = "bob@example.com" |
| } |
| - if _, err = s.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: address}, ReadFixture(t, "hello")); err != nil { |
| + if _, err = s.ApplyUpdate(ctx, doc, localActor(owner, address), ReadFixture(t, "hello")); err != nil { |
| t.Fatal(err) |
| } |
| } |
diff --git a/internal/docs/identity.go b/internal/docs/identity.go| index 6fdae35..f070227 100644 |
| --- a/internal/docs/identity.go |
| +++ b/internal/docs/identity.go |
| @@ -2,7 +2,9 @@ package docs |
| import ( |
| "context" |
| + "database/sql" |
| "errors" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| "net/http" |
| "time" |
| @@ -21,8 +23,9 @@ var ( |
| // requestIdentity keeps the verified session separate from the local person |
| // and address that own documents and hold grants. |
| type requestIdentity struct { |
| - Actor Actor |
| - Session sessions.Session |
| + Actor Actor |
| + HomeSubject string |
| + Session sessions.Session |
| } |
| type identityContextKey struct{} |
| @@ -32,6 +35,7 @@ func currentIdentity(r *http.Request) (requestIdentity, bool) { |
| } |
| type identityResolver struct { |
| + team *teamRuntime |
| store *Store |
| issuer string |
| // Return zero for an unknown link. Errors must not contain identity values. |
| @@ -43,7 +47,20 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| if !ok || session.Subject == "" || v.store == nil { |
| return requestIdentity{}, errIdentityRefused |
| } |
| + if v.store.team != nil && v.team == nil { |
| + return requestIdentity{}, errIdentityRefused |
| + } |
| if session.Method == homeAssertionMethod { |
| + if v.team != nil { |
| + p, err := v.team.principals.Resolve(r.Context(), v.team.bindings.Issuer, session.Subject) |
| + if errors.Is(err, principalstore.ErrRefused) { |
| + return requestIdentity{}, errIdentityRefused |
| + } |
| + if err != nil { |
| + return requestIdentity{}, errIdentityStorage |
| + } |
| + return requestIdentity{Actor: Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress}, Session: session, HomeSubject: session.Subject}, nil |
| + } |
| if v.issuer == "" || v.linkedPerson == nil { |
| return requestIdentity{}, errIdentityRefused |
| } |
| @@ -68,7 +85,10 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| if address == "" { |
| return requestIdentity{}, errIdentityRefused |
| } |
| - return requestIdentity{Actor: Actor{PersonID: p.ID, Subject: address}, Session: session}, nil |
| + return requestIdentity{Actor: localActor(p.ID, address), Session: session}, nil |
| + } |
| + if v.team != nil && session.Method != "magiclink" && session.Method != "keymail" { |
| + return requestIdentity{}, errIdentityRefused |
| } |
| // The local authentication plugin verified this address. Preserve its |
| // existing get-or-create behavior, including opt-in sample seeding. |
| @@ -76,7 +96,22 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| if err != nil { |
| return requestIdentity{}, errIdentityStorage |
| } |
| - return requestIdentity{Actor: Actor{PersonID: person, Subject: session.Subject}, Session: session}, nil |
| + id := requestIdentity{Actor: localActor(person, session.Subject), Session: session} |
| + if v.team != nil { |
| + p, err := v.team.principals.Person(r.Context(), person) |
| + if err != nil { |
| + return requestIdentity{}, errIdentityStorage |
| + } |
| + if p.Kind != principalstore.Local { |
| + return requestIdentity{}, errIdentityRefused |
| + } |
| + id.Actor = Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress} |
| + err = v.team.db.QueryRowContext(r.Context(), "SELECT subject FROM docs_identity_links WHERE issuer=? AND person_id=?", v.team.bindings.Issuer, p.ID).Scan(&id.HomeSubject) |
| + if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| + return requestIdentity{}, errIdentityStorage |
| + } |
| + } |
| + return id, nil |
| } |
| // middleware belongs after RequireSession and before admission. A verified |
| @@ -112,6 +147,9 @@ func requestActor(r *http.Request, store *Store) (Actor, error) { |
| if id, ok := currentIdentity(r); ok { |
| return id.Actor, nil |
| } |
| + if store.team != nil { |
| + return Actor{}, errIdentityRefused |
| + } |
| id, err := (identityResolver{store: store}).resolve(r) |
| return id.Actor, err |
| } |
diff --git a/internal/docs/identity_test.go b/internal/docs/identity_test.go| index 2e9bd62..dc0fd88 100644 |
| --- a/internal/docs/identity_test.go |
| +++ b/internal/docs/identity_test.go |
| @@ -99,7 +99,7 @@ func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) { |
| end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| reached = true |
| a, err := h.actor(r) |
| - if err != nil || a.PersonID != alice || a.Subject != "alice@example.com" { |
| + if err != nil || a.PersonID != alice || (a.VerifiedAddress == nil || *a.VerifiedAddress != "alice@example.com") { |
| t.Fatal("linked actor changed", a, err) |
| } |
| id, err := h.owner(r) |
| @@ -140,7 +140,7 @@ func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) { |
| t.Fatal(err) |
| } |
| id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod)) |
| - if err != nil || id.Actor.Subject != "renamed@example.com" { |
| + if err != nil || (id.Actor.VerifiedAddress == nil || *id.Actor.VerifiedAddress != "renamed@example.com") { |
| t.Fatal("stale local address") |
| } |
| } |
diff --git a/internal/docs/links.go b/internal/docs/links.go| index d6ef71f..5fb03ce 100644 |
| --- a/internal/docs/links.go |
| +++ b/internal/docs/links.go |
| @@ -99,7 +99,7 @@ func (s *Store) Publish(ctx context.Context, owner int64, sqid string) (string, |
| // replaced are the same outcome only if nothing else ever holds a |
| // reference to the row. Nothing does, and this way the intent reads |
| // off the code. |
| - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + err = s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error { |
| if err := tx.Where("doc_id = ?", doc.ID).Delete(&Link{}).Error; err != nil { |
| return err |
| } |
| @@ -121,7 +121,7 @@ func (s *Store) Unpublish(ctx context.Context, owner int64, sqid string) error { |
| if err != nil { |
| return err |
| } |
| - return s.db.WithContext(ctx).Where("doc_id = ?", doc.ID).Delete(&Link{}).Error |
| + return s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error { return tx.Where("doc_id = ?", doc.ID).Delete(&Link{}).Error }) |
| } |
| // IsPublished reports whether a document currently has a public link. |
diff --git a/internal/docs/principalstore/migrations/0001_principals.sql b/internal/docs/principalstore/migrations/0001_principals.sql| new file mode 100644 |
| index 0000000..70a90fd |
| --- /dev/null |
| +++ b/internal/docs/principalstore/migrations/0001_principals.sql |
| @@ -0,0 +1,47 @@ |
| +CREATE TABLE docs_principal_preflight (ok INTEGER CHECK(ok=1)); |
| +INSERT INTO docs_principal_preflight SELECT CASE WHEN |
| + NOT EXISTS(SELECT 1 FROM people WHERE id <= 0 OR id >= 4503599627370496) |
| + AND NOT EXISTS(SELECT 1 FROM documents d WHERE d.owner_id < 0 OR (d.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM people p WHERE p.id=d.owner_id))) |
| + AND NOT EXISTS(SELECT 1 FROM grants g WHERE g.created_by < 0 OR (g.created_by > 0 AND NOT EXISTS(SELECT 1 FROM people p WHERE p.id=g.created_by))) |
| + AND NOT EXISTS(SELECT 1 FROM pragma_foreign_key_check) |
| + AND (SELECT foreign_keys FROM pragma_foreign_keys)=1 |
| + THEN 1 ELSE 0 END; |
| +DROP TABLE docs_principal_preflight; |
| +CREATE TABLE docs_principals ( |
| + id INTEGER PRIMARY KEY, |
| + kind TEXT NOT NULL CHECK((kind='local' AND id BETWEEN 1 AND 4503599627370495) OR (kind='home' AND id BETWEEN 4503599627370496 AND 9007199254740991)), |
| + created_at DATETIME NOT NULL |
| +); |
| +INSERT INTO docs_principals SELECT id,'local',created_at FROM people; |
| +CREATE TABLE docs_home_identities ( |
| + issuer TEXT NOT NULL, |
| + subject TEXT NOT NULL, |
| + principal_id INTEGER NOT NULL UNIQUE REFERENCES docs_principals(id), |
| + PRIMARY KEY(issuer,subject) |
| +); |
| +CREATE TABLE docs_home_allocator ( |
| + singleton INTEGER PRIMARY KEY CHECK(singleton=1), |
| + high_water INTEGER NOT NULL CHECK(typeof(high_water)='integer' AND high_water BETWEEN 4503599627370495 AND 9007199254740991) |
| +); |
| +INSERT INTO docs_home_allocator VALUES(1,4503599627370495); |
| +CREATE TRIGGER docs_people_mirror AFTER INSERT ON people BEGIN |
| + SELECT CASE WHEN NEW.id < 1 OR NEW.id >= 4503599627370496 THEN RAISE(ABORT,'docs principal: refused') END; |
| + INSERT INTO docs_principals VALUES(NEW.id,'local',NEW.created_at); |
| +END; |
| +CREATE TRIGGER docs_people_keep_id BEFORE UPDATE OF id ON people WHEN NEW.id != OLD.id BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_people_keep_row BEFORE DELETE ON people BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_principals_keep_identity BEFORE UPDATE OF id,kind ON docs_principals WHEN NEW.id != OLD.id OR NEW.kind != OLD.kind BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_principals_keep_row BEFORE DELETE ON docs_principals BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_allocator_monotonic BEFORE UPDATE ON docs_home_allocator WHEN NEW.singleton != OLD.singleton OR NEW.high_water <= OLD.high_water BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_allocator_keep_row BEFORE DELETE ON docs_home_allocator BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identity_kind BEFORE INSERT ON docs_home_identities WHEN NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.principal_id AND kind='home') BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identity_unique BEFORE INSERT ON docs_home_identities WHEN EXISTS(SELECT 1 FROM docs_identity_links WHERE issuer=NEW.issuer AND subject=NEW.subject) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_local_identity_unique BEFORE INSERT ON docs_identity_links WHEN EXISTS(SELECT 1 FROM docs_home_identities WHERE issuer=NEW.issuer AND subject=NEW.subject) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identities_keep_update BEFORE UPDATE ON docs_home_identities BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identities_keep_delete BEFORE DELETE ON docs_home_identities BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_identity_links_keep_update BEFORE UPDATE ON docs_identity_links BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_identity_links_keep_delete BEFORE DELETE ON docs_identity_links BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_documents_principal_insert BEFORE INSERT ON documents WHEN NEW.owner_id < 0 OR (NEW.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.owner_id)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_documents_principal_update BEFORE UPDATE OF owner_id ON documents WHEN NEW.owner_id < 0 OR (NEW.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.owner_id)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_grants_principal_insert BEFORE INSERT ON grants WHEN NEW.created_by < 0 OR (NEW.created_by > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.created_by)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_grants_principal_update BEFORE UPDATE OF created_by ON grants WHEN NEW.created_by < 0 OR (NEW.created_by > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.created_by)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
diff --git a/internal/docs/principalstore/store.go b/internal/docs/principalstore/store.go| new file mode 100644 |
| index 0000000..e3409cb |
| --- /dev/null |
| +++ b/internal/docs/principalstore/store.go |
| @@ -0,0 +1,215 @@ |
| +// Package principalstore allocates identity records only. A principal is not |
| +// membership, an authenticated session, or permission to read a document. |
| +package principalstore |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "embed" |
| + "errors" |
| + "net/url" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| + |
| +// Schema follows Docs and linkstore schemas. It remains unmounted until the |
| +// combined migration, admission and rollback release has been reviewed. |
| +var Schema = migrate.MustFromFS(migrations, "docs_principal") |
| + |
| +const HomeIDMin int64 = 1 << 52 |
| +const HomeIDMax int64 = 1<<53 - 1 |
| + |
| +var ( |
| + ErrRefused = errors.New("docs principal: refused") |
| + ErrUnavailable = errors.New("docs principal: unavailable") |
| +) |
| + |
| +type Kind string |
| + |
| +const ( |
| + Local Kind = "local" |
| + Home Kind = "home" |
| +) |
| + |
| +type Principal struct { |
| + ID int64 |
| + Kind Kind |
| + // Nil means no verified local address. Never substitute an empty or invented |
| + // address in allowlists, sample creation or grant equality queries. |
| + VerifiedAddress *string |
| +} |
| + |
| +// HomeIdentity is supplied only after shared handoff verification and replay |
| +// consumption. The store cannot authenticate a browser or an assertion. |
| +type HomeIdentity struct{ Issuer, Subject string } |
| +type MembershipRequest struct{ Issuer, Team, Subject string } |
| +type Config struct { |
| + Issuer, Team string |
| + // RequireMembership consults the authoritative roster cache outside the SQL |
| + // transaction. Missing/expired/untrusted snapshot is ErrUnavailable; a fresh |
| + // complete snapshot lacking the person is ErrRefused. Success is a bounded |
| + // observation, not a durable grant. Each later access must recheck authority. |
| + RequireMembership func(context.Context, MembershipRequest) error |
| +} |
| +type Store struct { |
| + db *sql.DB |
| + cfg Config |
| +} |
| + |
| +func New(db *sql.DB, cfg Config) (*Store, error) { |
| + if db == nil || !origin(cfg.Issuer) || !opaque(cfg.Team, 64) { |
| + return nil, ErrRefused |
| + } |
| + var foreignKeys int |
| + if err := db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + if foreignKeys != 1 { |
| + return nil, ErrRefused |
| + } |
| + return &Store{db: db, cfg: cfg}, nil |
| +} |
| + |
| +func (s *Store) Person(ctx context.Context, id int64) (Principal, error) { |
| + if id <= 0 || id > HomeIDMax { |
| + return Principal{}, ErrRefused |
| + } |
| + return readPerson(s.db.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id)) |
| +} |
| +func readPerson(row *sql.Row) (Principal, error) { |
| + var p Principal |
| + var address sql.NullString |
| + err := row.Scan(&p.ID, &p.Kind, &address) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return Principal{}, ErrRefused |
| + } |
| + if err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + switch p.Kind { |
| + case Local: |
| + if p.ID <= 0 || p.ID >= HomeIDMin || !address.Valid { |
| + return Principal{}, ErrRefused |
| + } |
| + a := strings.ToLower(strings.TrimSpace(address.String)) |
| + if a == "" { |
| + return Principal{}, ErrRefused |
| + } |
| + p.VerifiedAddress = &a |
| + case Home: |
| + if p.ID < HomeIDMin || p.ID > HomeIDMax || address.Valid { |
| + return Principal{}, ErrRefused |
| + } |
| + default: |
| + return Principal{}, ErrRefused |
| + } |
| + return p, nil |
| +} |
| + |
| +// Resolve reads either an existing local link or a Home-only identity. It does |
| +// not create a record and returns no standing from a stored mapping. |
| +func (s *Store) Resolve(ctx context.Context, issuer, subject string) (Principal, error) { |
| + if issuer != s.cfg.Issuer || !opaque(subject, 512) { |
| + return Principal{}, ErrRefused |
| + } |
| + var id int64 |
| + var count int |
| + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MIN(id),0),COUNT(*) FROM (SELECT person_id AS id FROM docs_identity_links WHERE issuer=? AND subject=? UNION ALL SELECT principal_id AS id FROM docs_home_identities WHERE issuer=? AND subject=?)`, issuer, subject, issuer, subject).Scan(&id, &count) |
| + if err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if count != 1 { |
| + return Principal{}, ErrRefused |
| + } |
| + return s.Person(ctx, id) |
| +} |
| + |
| +// EnsureHome requires a current membership check even if another call has |
| +// already created this Home-only identity. Use Resolve for identity-only reads. |
| +func (s *Store) EnsureHome(ctx context.Context, identity HomeIdentity) (Principal, error) { |
| + if identity.Issuer != s.cfg.Issuer || !opaque(identity.Subject, 512) || s.cfg.RequireMembership == nil { |
| + return Principal{}, ErrRefused |
| + } |
| + if err := s.cfg.RequireMembership(ctx, MembershipRequest{identity.Issuer, s.cfg.Team, identity.Subject}); err != nil { |
| + if errors.Is(err, ErrRefused) { |
| + return Principal{}, ErrRefused |
| + } |
| + return Principal{}, ErrUnavailable |
| + } |
| + tx, err := s.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + // Take the writer lock before reading either mapping. The no-op write leaves |
| + // the allocator untouched and makes concurrent first arrivals one winner. |
| + if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=high_water WHERE singleton=0`); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + var local int |
| + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM docs_identity_links WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&local); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if local != 0 { |
| + return Principal{}, ErrRefused |
| + } |
| + var id int64 |
| + err = tx.QueryRowContext(ctx, `SELECT principal_id FROM docs_home_identities WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&id) |
| + if err == nil { |
| + p, err := readPerson(tx.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id)) |
| + if err != nil { |
| + return Principal{}, err |
| + } |
| + if p.Kind != Home { |
| + return Principal{}, ErrRefused |
| + } |
| + if tx.Commit() != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + return p, nil |
| + } |
| + if !errors.Is(err, sql.ErrNoRows) { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if err = tx.QueryRowContext(ctx, `SELECT high_water FROM docs_home_allocator WHERE singleton=1`).Scan(&id); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if id < HomeIDMin-1 || id >= HomeIDMax { |
| + return Principal{}, ErrRefused |
| + } |
| + id++ |
| + if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=? WHERE singleton=1`, id); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if _, err = tx.ExecContext(ctx, `INSERT INTO docs_principals(id,kind,created_at) VALUES(?,'home',?)`, id, time.Now().UTC()); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if _, err = tx.ExecContext(ctx, `INSERT INTO docs_home_identities(issuer,subject,principal_id) VALUES(?,?,?)`, identity.Issuer, identity.Subject, id); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if tx.Commit() != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + return Principal{ID: id, Kind: Home}, nil |
| +} |
| +func opaque(s string, max int) bool { |
| + if s == "" || len(s) > max || !utf8.ValidString(s) { |
| + return false |
| + } |
| + for _, r := range s { |
| + if r <= 32 || r == 127 || r == '@' { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func origin(s string) bool { |
| + u, e := url.Parse(s) |
| + return e == nil && len(s) <= 2048 && u.Scheme == "https" && u.Host != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == s && !strings.ContainsAny(s, "\\\r\n\t ") |
| +} |
diff --git a/internal/docs/principalstore/store_test.go b/internal/docs/principalstore/store_test.go| new file mode 100644 |
| index 0000000..c443785 |
| --- /dev/null |
| +++ b/internal/docs/principalstore/store_test.go |
| @@ -0,0 +1,274 @@ |
| +package principalstore_test |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| + "github.com/carlosframework/docs/internal/docs/linkstore" |
| + p "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +const issuer = "https://id.oficina.works" |
| + |
| +func fixture(t *testing.T, apply bool) (*db.DB, *p.Store) { |
| + t.Helper() |
| + d, e := db.Open(filepath.Join(t.TempDir(), "docs.db"), nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, e = migrate.Apply(context.Background(), d, migrate.Merge(docs.Schema, linkstore.Schema)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if apply { |
| + if _, e = migrate.Apply(context.Background(), d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + s, e := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1", RequireMembership: func(_ context.Context, r p.MembershipRequest) error { |
| + if r.Issuer != issuer || r.Team != "A1" || r.Subject == "absent" { |
| + return p.ErrRefused |
| + } |
| + return nil |
| + }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return d, s |
| +} |
| +func exec(t *testing.T, d *db.DB, q string, args ...any) { |
| + t.Helper() |
| + if _, e := d.Writer().Exec(q, args...); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func local(t *testing.T, d *db.DB, address string) int64 { |
| + t.Helper() |
| + r, e := d.Writer().Exec(`INSERT INTO people(email,created_at)VALUES(?,CURRENT_TIMESTAMP)`, address) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + id, e := r.LastInsertId() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return id |
| +} |
| +func TestAllocationIsolationAddressAbsenceAndNoSamples(t *testing.T) { |
| + d, s := fixture(t, false) |
| + a := local(t, d, "alice@example.invalid") |
| + exec(t, d, `INSERT INTO docs_identity_links VALUES(?,?,?)`, issuer, "alice", a) |
| + if _, e := migrate.Apply(context.Background(), d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + old, e := s.Resolve(context.Background(), issuer, "alice") |
| + if e != nil || old.ID != a || old.Kind != p.Local || old.VerifiedAddress == nil || *old.VerifiedAddress != "alice@example.invalid" { |
| + t.Fatal(old, e) |
| + } |
| + h, e := s.EnsureHome(context.Background(), p.HomeIdentity{Issuer: issuer, Subject: "new-home"}) |
| + if e != nil || h.ID != p.HomeIDMin || h.Kind != p.Home || h.VerifiedAddress != nil { |
| + t.Fatal(h, e) |
| + } |
| + b := local(t, d, "bob@example.invalid") |
| + if b != a+1 { |
| + t.Fatal("trigger changed LastInsertId", a, b) |
| + } |
| + got, e := s.Person(context.Background(), b) |
| + if e != nil || got.ID != b || got.Kind != p.Local { |
| + t.Fatal(got, e) |
| + } |
| + resolved, e := s.Resolve(context.Background(), issuer, "new-home") |
| + if e != nil || resolved.ID != h.ID || resolved.VerifiedAddress != nil { |
| + t.Fatal(resolved, e) |
| + } |
| + for _, table := range []string{"documents", "grants"} { |
| + var n int |
| + if e = d.Writer().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); e != nil || n != 0 { |
| + t.Fatal(table, n, e) |
| + } |
| + } |
| + if _, e = s.EnsureHome(context.Background(), p.HomeIdentity{Issuer: issuer, Subject: "alice"}); !errors.Is(e, p.ErrRefused) { |
| + t.Fatal("merged local account", e) |
| + } |
| + if _, e = d.Writer().Exec(`INSERT INTO docs_identity_links VALUES(?,?,?)`, issuer, "new-home", b); e == nil { |
| + t.Fatal("cross-table collision accepted") |
| + } |
| +} |
| +func TestMembershipSeamAndIdentityOnlyResolve(t *testing.T) { |
| + d, s := fixture(t, true) |
| + ctx := context.Background() |
| + for _, failure := range []error{p.ErrRefused, p.ErrUnavailable, errors.New("secret diagnostic")} { |
| + cfg := p.Config{Issuer: issuer, Team: "A1", RequireMembership: func(context.Context, p.MembershipRequest) error { return failure }} |
| + blocked, e := p.New(d.Writer(), cfg) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + _, e = blocked.EnsureHome(ctx, p.HomeIdentity{Issuer: issuer, Subject: "new"}) |
| + want := p.ErrUnavailable |
| + if failure == p.ErrRefused { |
| + want = p.ErrRefused |
| + } |
| + if e != want { |
| + t.Fatal(e) |
| + } |
| + } |
| + none, _ := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1"}) |
| + if _, e := none.EnsureHome(ctx, p.HomeIdentity{issuer, "new"}); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| + var n int |
| + d.Writer().QueryRow(`SELECT COUNT(*) FROM docs_principals`).Scan(&n) |
| + if n != 0 { |
| + t.Fatal("unapproved principal", n) |
| + } |
| + h, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "member"}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + got, e := none.Resolve(ctx, issuer, "member") |
| + if e != nil || got.ID != h.ID { |
| + t.Fatal(got, e) |
| + } |
| + for _, id := range []p.HomeIdentity{{"https://foreign.example", "member"}, {issuer, "mail@example.invalid"}, {issuer, ""}} { |
| + if _, e = s.EnsureHome(ctx, id); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| + } |
| +} |
| +func TestConcurrentCreationAndImmutableIDs(t *testing.T) { |
| + d, s := fixture(t, true) |
| + ctx := context.Background() |
| + var wg sync.WaitGroup |
| + ids := make(chan int64, 16) |
| + errs := make(chan error, 16) |
| + for range 16 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + v, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "same"}) |
| + if e != nil { |
| + errs <- e |
| + } else { |
| + ids <- v.ID |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + close(ids) |
| + close(errs) |
| + for e := range errs { |
| + t.Fatal(e) |
| + } |
| + for id := range ids { |
| + if id != p.HomeIDMin { |
| + t.Fatal(id) |
| + } |
| + } |
| + a := local(t, d, "local@example.invalid") |
| + for _, q := range []string{ |
| + `DELETE FROM people`, `UPDATE people SET id=id+1`, `DELETE FROM docs_principals`, `UPDATE docs_principals SET kind='local' WHERE kind='home'`, |
| + `DELETE FROM docs_home_identities`, `UPDATE docs_home_identities SET subject='other'`, `UPDATE docs_home_allocator SET high_water=high_water-1`, |
| + fmt.Sprintf(`INSERT INTO people(id,email,created_at) VALUES(%d,'collision@example.invalid',CURRENT_TIMESTAMP)`, p.HomeIDMin), |
| + fmt.Sprintf(`INSERT INTO docs_home_identities VALUES('%s','wrong-kind',%d)`, issuer, a), |
| + `INSERT INTO docs_identity_links VALUES('https://id.oficina.works','missing',12345)`, |
| + } { |
| + if _, e := d.Writer().Exec(q); e == nil { |
| + t.Fatalf("accepted mutation: %s", q) |
| + } |
| + } |
| + if _, e := migrate.Apply(ctx, d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := migrate.Apply(ctx, d, migrate.Merge(docs.Schema, linkstore.Schema)); e != nil { |
| + t.Fatal("older known migration set rejected newer ledger", e) |
| + } |
| + rows, e := d.Writer().Query(`PRAGMA foreign_key_check`) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer rows.Close() |
| + if rows.Next() { |
| + t.Fatal("foreign key damage") |
| + } |
| +} |
| +func TestSentinelsReferencesAndExhaustion(t *testing.T) { |
| + d, s := fixture(t, true) |
| + ctx := context.Background() |
| + h, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "owner"}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for i, owner := range []int64{0, h.ID} { |
| + exec(t, d, `INSERT INTO documents(id,sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES(?,?,?,?,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)`, i+1, fmt.Sprint(i), "Title", "guid", owner) |
| + } |
| + exec(t, d, `INSERT INTO grants(doc_id,subject,role,created_by)VALUES(1,'reader@example.invalid','reader',0)`) |
| + exec(t, d, `UPDATE documents SET owner_id=owner_id,title=title`) |
| + exec(t, d, `UPDATE grants SET created_by=created_by`) |
| + for _, q := range []string{`UPDATE documents SET owner_id=1`, `UPDATE grants SET created_by=1`, `UPDATE documents SET owner_id=-1`} { |
| + if _, e = d.Writer().Exec(q); e == nil { |
| + t.Fatal(q) |
| + } |
| + } |
| + exec(t, d, `UPDATE docs_home_allocator SET high_water=?`, p.HomeIDMax) |
| + if _, e = s.EnsureHome(ctx, p.HomeIdentity{issuer, "past-cap"}); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| + if _, e = s.Resolve(ctx, issuer, "past-cap"); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| +} |
| +func TestPreflightIsAtomicAndPreservesExistingSchema(t *testing.T) { |
| + for _, q := range []string{ |
| + fmt.Sprintf(`INSERT INTO people VALUES(%d,'range@example.invalid',CURRENT_TIMESTAMP)`, p.HomeIDMin), |
| + `INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES('x','Title','g',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,12)`, |
| + `INSERT INTO grants(doc_id,subject,role,created_by)VALUES(1,'reader@example.invalid','reader',12)`, |
| + } { |
| + t.Run(q, func(t *testing.T) { |
| + d, _ := fixture(t, false) |
| + exec(t, d, q) |
| + if _, e := migrate.Apply(context.Background(), d, p.Schema); e == nil { |
| + t.Fatal("bad preflight accepted") |
| + } |
| + var n int |
| + if e := d.Writer().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'docs_principal%' OR name LIKE 'docs_home_%'`).Scan(&n); e != nil || n != 0 { |
| + t.Fatal("partial migration", n, e) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRequiresForeignKeysAndPreservesEncodedBytes(t *testing.T) { |
| + d, _ := fixture(t, false) |
| + id := local(t, d, "owner@example.invalid") |
| + exec(t, d, `INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id) VALUES('doc','kept','guid',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)`, id) |
| + exec(t, d, `INSERT INTO doc_updates(doc_id,seq,update_bin,created_at,content_encoding)VALUES(1,1,X'00FFAABB',CURRENT_TIMESTAMP,1)`) |
| + exec(t, d, `INSERT INTO doc_snapshots(doc_id,seq,state,abi_version,created_at,content_encoding)VALUES(1,1,X'010203FF',2,CURRENT_TIMESTAMP,1)`) |
| + exec(t, d, `INSERT INTO content_sealing VALUES(1,1,'fixed-key-fingerprint',X'090807',0)`) |
| + if _, e := migrate.Apply(context.Background(), d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + var update, snapshot, key string |
| + if e := d.Writer().QueryRow(`SELECT hex(update_bin)||':'||content_encoding FROM doc_updates`).Scan(&update); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := d.Writer().QueryRow(`SELECT hex(state)||':'||content_encoding FROM doc_snapshots`).Scan(&snapshot); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := d.Writer().QueryRow(`SELECT key_fingerprint||':'||hex(key_check)||':'||encoding FROM content_sealing`).Scan(&key); e != nil { |
| + t.Fatal(e) |
| + } |
| + if update != "00FFAABB:1" || snapshot != "010203FF:1" || key != "fixed-key-fingerprint:090807:1" { |
| + t.Fatal(update, snapshot, key) |
| + } |
| + exec(t, d, `PRAGMA foreign_keys=OFF`) |
| + if _, e := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1"}); e != p.ErrRefused { |
| + t.Fatal("foreign keys disabled", e) |
| + } |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_main.go.txt b/internal/docs/principalstore/testdata/compat_main.go.txt| new file mode 100644 |
| index 0000000..63c67e7 |
| --- /dev/null |
| +++ b/internal/docs/principalstore/testdata/compat_main.go.txt |
| @@ -0,0 +1,102 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "github.com/carlosframework/docs/internal/core" |
| + "github.com/carlosframework/docs/internal/docs" |
| + "github.com/carlosframework/rastrillo/db" |
| + "io" |
| + "log/slog" |
| + "os" |
| +) |
| + |
| +func must(e error) { |
| + if e != nil { |
| + panic(e) |
| + } |
| +} |
| +func main() { |
| + path := flag.String("db", "", "disposable database") |
| + address := flag.String("address", "", "synthetic local account") |
| + phase := flag.String("phase", "", "phase") |
| + flag.Parse() |
| + ctx := context.Background() |
| + app, e := docs.New(docs.Config{DBPath: *path, Env: "development", Origin: "https://compat.example", SampleDocuments: "false", Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) |
| + must(e) |
| + defer app.Close() |
| + d, e := db.Open(*path, nil) |
| + must(e) |
| + defer d.Close() |
| + id, e := app.Store().EnsurePerson(ctx, *address) |
| + must(e) |
| + principal := principalStep(ctx, d, *phase) |
| + if principal > 0 { |
| + var n int |
| + must(d.Writer().QueryRow("SELECT COUNT(*) FROM documents WHERE owner_id=?", principal).Scan(&n)) |
| + if n == 0 { |
| + _, e := app.Store().Create(ctx, principal, "home-owned") |
| + must(e) |
| + } |
| + } |
| + var sqid string |
| + e = d.Writer().QueryRow(`SELECT sqid FROM documents WHERE title='kept-content'`).Scan(&sqid) |
| + if e != nil { |
| + doc, e := app.Store().Create(ctx, id, "kept-content") |
| + must(e) |
| + sqid = doc.Sqid |
| + update, e := os.ReadFile("fixtures/vectors/fragment/hello.update.bin") |
| + must(e) |
| + _, e = app.Store().ApplyUpdate(ctx, doc, fixtureActor(id, *address), update) |
| + must(e) |
| + must(app.Store().Grant(ctx, id, sqid, "reader@example.invalid", docs.RoleWriter)) |
| + _, e = d.Writer().Exec(`INSERT INTO doc_snapshots(doc_id,seq,state,abi_version,created_at)VALUES(?,1,?,?,CURRENT_TIMESTAMP)`, doc.ID, update, core.ABIVersion) |
| + must(e) |
| + _, e = d.Writer().Exec(`INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES('orphan','orphan','orphan',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)`) |
| + must(e) |
| + _, e = d.Writer().Exec(`INSERT INTO grants(doc_id,subject,role,created_by)VALUES(0,'sentinel@example.invalid','reader',0)`) |
| + must(e) |
| + } |
| + var owner int64 |
| + must(d.Writer().QueryRow(`SELECT owner_id FROM documents WHERE sqid=?`, sqid).Scan(&owner)) |
| + doc, e := app.Store().Get(ctx, owner, sqid) |
| + must(e) |
| + text, e := app.Store().Text(ctx, doc) |
| + must(e) |
| + if text != "hello world" { |
| + panic(text) |
| + } |
| + if _, e = d.Writer().Exec(`UPDATE documents SET owner_id=owner_id`); e != nil { |
| + panic(e) |
| + } |
| + if _, e = d.Writer().Exec(`UPDATE grants SET created_by=created_by`); e != nil { |
| + panic(e) |
| + } |
| + rows, e := d.Writer().Query(`SELECT update_bin,content_encoding FROM doc_updates WHERE doc_id=(SELECT id FROM documents WHERE title='kept-content') UNION ALL SELECT state,content_encoding FROM doc_snapshots WHERE doc_id=(SELECT id FROM documents WHERE title='kept-content')`) |
| + must(e) |
| + h := sha256.New() |
| + encodings := []int{} |
| + for rows.Next() { |
| + var b []byte |
| + var encoding int |
| + must(rows.Scan(&b, &encoding)) |
| + h.Write(b) |
| + encodings = append(encodings, encoding) |
| + } |
| + must(rows.Err()) |
| + rows.Close() |
| + var author int64 |
| + must(d.Writer().QueryRow(`SELECT created_by FROM grants WHERE subject='reader@example.invalid'`).Scan(&author)) |
| + var homeCount int |
| + must(d.Writer().QueryRow(`SELECT COUNT(*) FROM documents WHERE owner_id>=4503599627370496`).Scan(&homeCount)) |
| + var ledger int |
| + d.Writer().QueryRow(`SELECT COUNT(*) FROM docs_principals`).Scan(&ledger) |
| + out := map[string]any{"phase": *phase, "local_id": id, "home_id": principal, "owner": owner, "grant_author": author, "sqid": sqid, "content_hash": hex.EncodeToString(h.Sum(nil)), "encodings": encodings, "text": text, "principal_count": ledger, "home_owned": homeCount} |
| + b, e := json.Marshal(out) |
| + must(e) |
| + fmt.Println(string(b)) |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_new.go.txt b/internal/docs/principalstore/testdata/compat_new.go.txt| new file mode 100644 |
| index 0000000..b55d4a8 |
| --- /dev/null |
| +++ b/internal/docs/principalstore/testdata/compat_new.go.txt |
| @@ -0,0 +1,27 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/docs/internal/docs" |
| + "github.com/carlosframework/docs/internal/docs/linkstore" |
| + p "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +func principalStep(ctx context.Context, d *db.DB, phase string) int64 { |
| + _, e := migrate.Apply(ctx, d, migrate.Merge(linkstore.Schema, p.Schema)) |
| + must(e) |
| + s, e := p.New(d.Writer(), p.Config{Issuer: "https://id.oficina.works", Team: "A1", RequireMembership: func(context.Context, p.MembershipRequest) error { return nil }}) |
| + must(e) |
| + v, e := s.EnsureHome(ctx, p.HomeIdentity{Issuer: "https://id.oficina.works", Subject: "synthetic-member"}) |
| + must(e) |
| + if v.VerifiedAddress != nil { |
| + panic("invented address") |
| + } |
| + return v.ID |
| +} |
| + |
| +func fixtureActor(id int64, address string) docs.Actor { |
| + return docs.Actor{PersonID: id, VerifiedAddress: &address} |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_old.go.txt b/internal/docs/principalstore/testdata/compat_old.go.txt| new file mode 100644 |
| index 0000000..a0143f4 |
| --- /dev/null |
| +++ b/internal/docs/principalstore/testdata/compat_old.go.txt |
| @@ -0,0 +1,13 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/docs/internal/docs" |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| + |
| +func principalStep(context.Context, *db.DB, string) int64 { return 0 } |
| + |
| +func fixtureActor(id int64, address string) docs.Actor { |
| + return docs.Actor{PersonID: id, Subject: address} |
| +} |
diff --git a/internal/docs/publish.go b/internal/docs/publish.go| index 48562dd..3a4ea8d 100644 |
| --- a/internal/docs/publish.go |
| +++ b/internal/docs/publish.go |
| @@ -50,14 +50,14 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| h.logger.Error("docs: resolve public link", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| html, err := h.store.HTML(r.Context(), doc) |
| if err != nil { |
| h.logger.Error("docs: public document html", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -81,7 +81,7 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| ReadOnly: rastrillo.T(r, "docs.public.readonly"), |
| }); err != nil { |
| h.logger.Error("docs: render public document", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
| @@ -98,14 +98,13 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) { |
| // Member-only, as the other two share routes are. Publishing to the |
| // whole internet is the largest re-share there is. |
| - if !h.member(r) { |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + if !h.requireMemberRequest(w, r, true) { |
| return |
| } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for publish", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| sqid := r.PathValue("sqid") |
| @@ -130,6 +129,6 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| default: |
| h.logger.Error("docs: set public link", "sqid", sqid, "on", on, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 8dc5ac7..8122c49 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -187,6 +187,7 @@ var Catalog = rastrillo.Catalog{ |
| "docs.jumpto.placeholder": "Search documents", |
| "docs.jumpto.hint": "Type to search. Up and down to choose, Enter to open, Escape to close.", |
| "docs.jumpto.empty": "No documents match", |
| + "docs.team.partial": "We couldn’t check your team access. Only documents shared with you are shown.", |
| "docs.jumpto.failed": "Search is unavailable right now.", |
| "docs.jumpto.close": "Close", |
| @@ -577,11 +578,7 @@ func (rd *renderer) pageStatus(w http.ResponseWriter, r *http.Request, name stri |
| return fmt.Errorf("docs: render page %q: %w", name, err) |
| } |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - if status != 0 { |
| - w.WriteHeader(status) |
| - } |
| - _, err := buf.WriteTo(w) |
| - return err |
| + return writeGuardedBody(w, r, status, buf.Bytes()) |
| } |
| // vendoredStaticFS builds the static/ tree rastrillo.Assets serves — |
diff --git a/internal/docs/search.go b/internal/docs/search.go| index f1a0f83..f0203ac 100644 |
| --- a/internal/docs/search.go |
| +++ b/internal/docs/search.go |
| @@ -212,14 +212,14 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) { |
| who, err := h.actor(r) |
| if err != nil { |
| h.logger.Error("docs: resolve signed-in person", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| docs, err := h.store.SearchFor(r.Context(), who, r.URL.Query().Get("q")) |
| if err != nil { |
| h.logger.Error("docs: search documents", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -239,14 +239,17 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) { |
| body, err := json.Marshal(results) |
| if err != nil { |
| h.logger.Error("docs: encode search results", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| + if h.partialCatalog(r) != "" { |
| + w.Header().Set("Docs-Catalog-Partial", "1") |
| + } |
| w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| // A person's own document titles, and they change as they type. |
| w.Header().Set("Cache-Control", "no-store") |
| - if _, err := w.Write(body); err != nil { |
| + if err := writeGuardedBody(w, r, 0, body); err != nil { |
| h.logger.Error("docs: write search results", "err", err) |
| } |
| } |
| @@ -255,6 +258,7 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) { |
| // request. Both the list and a document carry it, so it lives here |
| // with the rest of search rather than on either page's own view data. |
| type jumpToView struct { |
| + Partial string |
| Label string |
| Placeholder string |
| Hint string |
| @@ -265,6 +269,7 @@ type jumpToView struct { |
| func newJumpToView(r *http.Request) jumpToView { |
| return jumpToView{ |
| + Partial: rastrillo.T(r, "docs.team.partial"), |
| Label: rastrillo.T(r, "docs.jumpto.label"), |
| Placeholder: rastrillo.T(r, "docs.jumpto.placeholder"), |
| Hint: rastrillo.T(r, "docs.jumpto.hint"), |
diff --git a/internal/docs/share.go b/internal/docs/share.go| index 1ee4c20..6bd3f01 100644 |
| --- a/internal/docs/share.go |
| +++ b/internal/docs/share.go |
| @@ -68,27 +68,27 @@ func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) { |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for share", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| sqid := r.PathValue("sqid") |
| doc, err := h.store.Get(r.Context(), owner, sqid) |
| if err != nil { |
| - http.NotFound(w, r) |
| + h.ownerLookupFailure(w, r, err) |
| return |
| } |
| grants, err := h.store.ListGrants(r.Context(), owner, sqid) |
| if err != nil { |
| h.logger.Error("docs: list grants", "sqid", sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| - h.renderShare(w, r, doc, grants, "") |
| + h.renderShare(w, r, doc, grants, "", http.StatusOK) |
| } |
| -func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Document, grants []*Grant, errKey string) { |
| +func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Document, grants []*Grant, errKey string, status int) { |
| // A failure to read the published state is logged and then rendered as |
| // "not published" rather than failing the page: the grant list above it |
| // is the part somebody came here for, and a 500 over the switch would |
| @@ -96,6 +96,10 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu |
| published, err := h.store.IsPublished(r.Context(), doc.OwnerID, doc.Sqid) |
| if err != nil { |
| h.logger.Error("docs: read published state", "sqid", doc.Sqid, "err", err) |
| + if h.store.team != nil { |
| + h.ownerLookupFailure(w, r, err) |
| + return |
| + } |
| } |
| // The URL appears only in the render that follows the publish that |
| @@ -152,9 +156,9 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu |
| if page.PublicURL != "" { |
| w.Header().Set("Cache-Control", "no-store") |
| } |
| - if err := h.render.page(w, r, "share", page); err != nil { |
| + if err := h.render.pageStatus(w, r, "share", page, status); err != nil { |
| h.logger.Error("docs: render share", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
| @@ -167,21 +171,20 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) { |
| // a document is a member power. A guest owns no documents (create is |
| // closed to them), so this is belt and braces — and it is the brace |
| // that holds if a guest ever acquires one. |
| - if !h.member(r) { |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + if !h.requireMemberRequest(w, r, true) { |
| return |
| } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for share", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| sqid := r.PathValue("sqid") |
| doc, err := h.store.Get(r.Context(), owner, sqid) |
| if err != nil { |
| - http.NotFound(w, r) |
| + h.ownerLookupFailure(w, r, err) |
| return |
| } |
| @@ -200,18 +203,13 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) { |
| grants, listErr := h.store.ListGrants(r.Context(), owner, sqid) |
| if listErr != nil { |
| h.logger.Error("docs: list grants after refusal", "sqid", sqid, "err", listErr) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| - // Content-Type before WriteHeader, or the header render.page |
| - // sets afterwards is dropped and the response works only by |
| - // sniffing. |
| - w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - w.WriteHeader(http.StatusBadRequest) |
| - h.renderShare(w, r, doc, grants, "docs.share.error.address") |
| + h.renderShare(w, r, doc, grants, "docs.share.error.address", http.StatusBadRequest) |
| default: |
| h.logger.Error("docs: grant", "sqid", sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| } |
| } |
| @@ -221,26 +219,33 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) { |
| // a document is a member power. A guest owns no documents (create is |
| // closed to them), so this is belt and braces — and it is the brace |
| // that holds if a guest ever acquires one. |
| - if !h.member(r) { |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + if !h.requireMemberRequest(w, r, true) { |
| return |
| } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for revoke", "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| sqid := r.PathValue("sqid") |
| if _, err := h.store.Get(r.Context(), owner, sqid); err != nil { |
| - http.NotFound(w, r) |
| + h.ownerLookupFailure(w, r, err) |
| return |
| } |
| if err := h.store.Revoke(r.Context(), owner, sqid, r.PostFormValue("address")); err != nil { |
| h.logger.Error("docs: revoke", "sqid", sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther) |
| } |
| + |
| +func (h *handlers) ownerLookupFailure(w http.ResponseWriter, r *http.Request, err error) { |
| + if h.store.team != nil && errors.Is(err, errTeamUnavailable) { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + http.NotFound(w, r) |
| +} |
diff --git a/internal/docs/snapshots.go b/internal/docs/snapshots.go| index 3ffe465..38d0bb5 100644 |
| --- a/internal/docs/snapshots.go |
| +++ b/internal/docs/snapshots.go |
| @@ -109,6 +109,9 @@ func (s *Store) writeSnapshot(ctx context.Context, doc *Document, h *core.Handle |
| CreatedAt: time.Now().UTC(), |
| } |
| return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := s.snapshotPermission(ctx, tx, doc.ID); err != nil { |
| + return err |
| + } |
| if err := s.prepareContentSnapshot(tx, doc.GUID, row); err != nil { |
| return err |
| } |
| @@ -148,6 +151,6 @@ func (s *Store) writeSnapshot(ctx context.Context, doc *Document, h *core.Handle |
| Delete(&DocSnapshot{}).Error; err != nil { |
| return fmt.Errorf("docs: delete superseded snapshots for document %d at seq %d: %w", doc.ID, seq, err) |
| } |
| - return nil |
| + return s.snapshotPermission(ctx, tx, doc.ID) |
| }) |
| } |
diff --git a/internal/docs/static/jumpto.5da80b47f2ea7b28.js b/internal/docs/static/jumpto.5da80b47f2ea7b28.js| new file mode 100644 |
| index 0000000..16ee9e9 |
| --- /dev/null |
| +++ b/internal/docs/static/jumpto.5da80b47f2ea7b28.js |
| @@ -0,0 +1 @@ |
| +function S(a){if(!a)return null;let i=a.querySelector("[docs-jumpto-input]"),f=a.querySelector("[docs-jumpto-list]"),u=a.querySelector("[docs-jumpto-status]"),E=a.querySelector("[docs-jumpto-close]");if(!i||!f||!u)return null;let y={empty:u.getAttribute("data-empty")||"",failed:u.getAttribute("data-failed")||"",partial:u.getAttribute("data-partial")||""},o=!1,c=[],r=-1,g=null,p=0,l=null;function w(){o||(o=!0,l=document.activeElement,a.hidden=!1,i.setAttribute("aria-expanded","true"),i.value="",h([]),m(""),i.focus())}function s(){o&&(o=!1,a.hidden=!0,i.setAttribute("aria-expanded","false"),i.removeAttribute("aria-activedescendant"),clearTimeout(g),p++,l&&document.contains(l)&&l.focus(),l=null)}function m(e){u.textContent=e,u.hidden=e===""}function h(e){c=e,r=e.length>0?0:-1,f.textContent="";for(let t=0;t<e.length;t++){let n=document.createElement("li");n.id=`docs-jumpto-option-${t}`,n.setAttribute("role","option"),n.setAttribute("aria-selected",t===r?"true":"false"),n.dataset.href=e[t].href;let d=document.createElement("span");d.textContent=e[t].title;let k=document.createElement("span");k.setAttribute("docs-jumpto-when",""),k.textContent=e[t].when,n.append(d,k),n.addEventListener("mousedown",j=>{j.preventDefault(),D(t)}),f.append(n)}b()}function b(){let e=f.children;for(let t=0;t<e.length;t++)e[t].setAttribute("aria-selected",t===r?"true":"false");r>=0&&e[r]?(i.setAttribute("aria-activedescendant",e[r].id),e[r].scrollIntoView({block:"nearest"})):i.removeAttribute("aria-activedescendant")}function A(e){c.length!==0&&(r=(r+e+c.length)%c.length,b())}function D(e){let t=c[e];t&&(window.location.href=t.href)}async function C(e){let t=++p;if(e.trim()===""){h([]),m("");return}try{let n=await fetch(`/documents/search?q=${encodeURIComponent(e)}`,{credentials:"same-origin"});if(!n.ok)throw new Error(`search: ${n.status}`);let d=await n.json();if(t!==p||!o)return;h(d),m(n.headers?.get("Docs-Catalog-Partial")==="1"?y.partial:d.length===0?y.empty:"")}catch(n){if(t!==p||!o)return;h([]),m(y.failed),console.error(n)}}return i.addEventListener("input",()=>{clearTimeout(g);let e=i.value;g=setTimeout(()=>C(e),120)}),i.addEventListener("keydown",e=>{if(!(e.isComposing||e.keyCode===229))switch(e.key){case"ArrowDown":e.preventDefault(),A(1);break;case"ArrowUp":e.preventDefault(),A(-1);break;case"Home":c.length&&(e.preventDefault(),r=0,b());break;case"End":c.length&&(e.preventDefault(),r=c.length-1,b());break;case"Enter":r>=0&&(e.preventDefault(),D(r));break;case"Escape":e.preventDefault(),s();break;case"Tab":e.preventDefault(),s();break;default:break}}),E&&E.addEventListener("click",()=>s()),a.addEventListener("mousedown",e=>{e.target===a&&s()}),addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),o?s():w();return}e.key==="Escape"&&o&&(e.preventDefault(),s())}),{show:w,hide:s,isOpen:()=>o}}var v=document.querySelector("[docs-jumpto]");if(v)try{S(v)}catch(a){console.error(a)}export{S as createJumpTo}; |
diff --git a/internal/docs/static/jumpto.a37448e7fcd21855.js b/internal/docs/static/jumpto.a37448e7fcd21855.js| deleted file mode 100644 |
| index a586444..0000000 |
| --- a/internal/docs/static/jumpto.a37448e7fcd21855.js |
| +++ /dev/null |
| @@ -1 +0,0 @@ |
| -function S(a){if(!a)return null;let i=a.querySelector("[docs-jumpto-input]"),f=a.querySelector("[docs-jumpto-list]"),u=a.querySelector("[docs-jumpto-status]"),E=a.querySelector("[docs-jumpto-close]");if(!i||!f||!u)return null;let g={empty:u.getAttribute("data-empty")||"",failed:u.getAttribute("data-failed")||""},o=!1,c=[],n=-1,y=null,p=0,l=null;function w(){o||(o=!0,l=document.activeElement,a.hidden=!1,i.setAttribute("aria-expanded","true"),i.value="",h([]),m(""),i.focus())}function s(){o&&(o=!1,a.hidden=!0,i.setAttribute("aria-expanded","false"),i.removeAttribute("aria-activedescendant"),clearTimeout(y),p++,l&&document.contains(l)&&l.focus(),l=null)}function m(e){u.textContent=e,u.hidden=e===""}function h(e){c=e,n=e.length>0?0:-1,f.textContent="";for(let t=0;t<e.length;t++){let r=document.createElement("li");r.id=`docs-jumpto-option-${t}`,r.setAttribute("role","option"),r.setAttribute("aria-selected",t===n?"true":"false"),r.dataset.href=e[t].href;let d=document.createElement("span");d.textContent=e[t].title;let k=document.createElement("span");k.setAttribute("docs-jumpto-when",""),k.textContent=e[t].when,r.append(d,k),r.addEventListener("mousedown",j=>{j.preventDefault(),D(t)}),f.append(r)}b()}function b(){let e=f.children;for(let t=0;t<e.length;t++)e[t].setAttribute("aria-selected",t===n?"true":"false");n>=0&&e[n]?(i.setAttribute("aria-activedescendant",e[n].id),e[n].scrollIntoView({block:"nearest"})):i.removeAttribute("aria-activedescendant")}function A(e){c.length!==0&&(n=(n+e+c.length)%c.length,b())}function D(e){let t=c[e];t&&(window.location.href=t.href)}async function C(e){let t=++p;if(e.trim()===""){h([]),m("");return}try{let r=await fetch(`/documents/search?q=${encodeURIComponent(e)}`,{credentials:"same-origin"});if(!r.ok)throw new Error(`search: ${r.status}`);let d=await r.json();if(t!==p||!o)return;h(d),m(d.length===0?g.empty:"")}catch(r){if(t!==p||!o)return;h([]),m(g.failed),console.error(r)}}return i.addEventListener("input",()=>{clearTimeout(y);let e=i.value;y=setTimeout(()=>C(e),120)}),i.addEventListener("keydown",e=>{if(!(e.isComposing||e.keyCode===229))switch(e.key){case"ArrowDown":e.preventDefault(),A(1);break;case"ArrowUp":e.preventDefault(),A(-1);break;case"Home":c.length&&(e.preventDefault(),n=0,b());break;case"End":c.length&&(e.preventDefault(),n=c.length-1,b());break;case"Enter":n>=0&&(e.preventDefault(),D(n));break;case"Escape":e.preventDefault(),s();break;case"Tab":e.preventDefault(),s();break;default:break}}),E&&E.addEventListener("click",()=>s()),a.addEventListener("mousedown",e=>{e.target===a&&s()}),addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),o?s():w();return}e.key==="Escape"&&o&&(e.preventDefault(),s())}),{show:w,hide:s,isOpen:()=>o}}var v=document.querySelector("[docs-jumpto]");if(v)try{S(v)}catch(a){console.error(a)}export{S as createJumpTo}; |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 5abefeb..f96c9bd 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -21,6 +21,7 @@ import ( |
| // the update log, and the document's actual text read back through |
| // the real CRDT core rather than any column here — see Text. |
| type Store struct { |
| + team *teamRuntime |
| db *gorm.DB |
| core *core.Runtime |
| content *contentCipher |
| @@ -254,13 +255,13 @@ func (s *Store) EnsurePerson(ctx context.Context, email string) (int64, error) { |
| // yet; the first is this feature having broken sign-in, which is the |
| // exact failure this whole plan exists to fix, not reintroduce. |
| func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string) { |
| - if !s.sampleDocuments { |
| + if s.team != nil || !s.sampleDocuments { |
| return |
| } |
| if s.seedOnlyMembers != nil && !s.seedOnlyMembers(email) { |
| return |
| } |
| - if err := Seed(ctx, s, Actor{PersonID: newOwner, Subject: email}); err != nil { |
| + if err := Seed(ctx, s, localActor(newOwner, email)); err != nil { |
| s.logger.Error("docs: seed sample documents for new person", "person", newOwner, "address", email, "err", err) |
| } |
| } |
| @@ -278,6 +279,10 @@ func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string) |
| // SKILL.md §3: "Creating takes the owner from the session, never the |
| // form." |
| func (s *Store) Create(ctx context.Context, owner int64, title string) (*Document, error) { |
| + actor := s.ownerActor(ctx, owner) |
| + if err := s.requireMember(ctx, actor); err != nil { |
| + return nil, err |
| + } |
| guid := uuid.NewString() |
| // math/rand/v2's Int64 returns a non-negative value in [0, 1<<63), |
| // so converting to uint64 never wraps — the brief's "a counter or |
| @@ -300,7 +305,15 @@ func (s *Store) Create(ctx context.Context, owner int64, title string) (*Documen |
| CreatedAt: now, |
| UpdatedAt: now, |
| } |
| - err = s.db.WithContext(ctx).Create(doc).Error |
| + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := s.requireMember(ctx, actor); err != nil { |
| + return err |
| + } |
| + if err := tx.Create(doc).Error; err != nil { |
| + return err |
| + } |
| + return s.requireMember(ctx, actor) |
| + }) |
| if err == nil { |
| return doc, nil |
| } |
| @@ -327,13 +340,14 @@ func (s *Store) Create(ctx context.Context, owner int64, title string) (*Documen |
| // above. |
| func (s *Store) List(ctx context.Context, owner int64) ([]*Document, error) { |
| var docs []*Document |
| - err := owned(s.db.WithContext(ctx), owner). |
| + err := s.permissionScope(ctx, s.db.WithContext(ctx), s.ownerActor(ctx, owner), permissionOwner). |
| Where("deleted_at IS NULL"). |
| Order("updated_at DESC, id DESC"). |
| Find(&docs).Error |
| if err != nil { |
| return nil, fmt.Errorf("docs: list documents: %w", err) |
| } |
| + s.rememberResponse(ctx, permissionOwner, docs...) |
| return docs, nil |
| } |
| @@ -345,12 +359,13 @@ func (s *Store) List(ctx context.Context, owner int64) ([]*Document, error) { |
| // document (spec §4.2). |
| func (s *Store) Get(ctx context.Context, owner int64, sqid string) (*Document, error) { |
| var doc Document |
| - err := owned(s.db.WithContext(ctx), owner). |
| + err := s.permissionScope(ctx, s.db.WithContext(ctx), s.ownerActor(ctx, owner), permissionOwner). |
| Where("sqid = ? AND deleted_at IS NULL", sqid). |
| First(&doc).Error |
| if err != nil { |
| - return nil, fmt.Errorf("docs: get document %q: %w", sqid, err) |
| + return nil, s.permissionError(ctx, s.ownerActor(ctx, owner), fmt.Errorf("docs: get document %q: %w", sqid, err)) |
| } |
| + s.rememberResponse(ctx, permissionOwner, &doc) |
| return &doc, nil |
| } |
| @@ -752,14 +767,8 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| // Reader and stranger are refused identically. The write path does not |
| // tell somebody which of the two they are, for the same reason §4.2 gives |
| // about reads. |
| - var writeCheck Document |
| - if err := writable(s.db.WithContext(ctx), actor). |
| - Where("documents.id = ?", doc.ID). |
| - First(&writeCheck).Error; err != nil { |
| - if errors.Is(err, gorm.ErrRecordNotFound) { |
| - return 0, fmt.Errorf("docs: apply update to document %d: %w", doc.ID, ErrNotWritable) |
| - } |
| - return 0, fmt.Errorf("docs: apply update to document %d: verify writer %d: %w", doc.ID, actor.PersonID, err) |
| + if err := s.checkPermission(ctx, s.db.WithContext(ctx), actor, doc.ID, permissionWrite); err != nil { |
| + return 0, err |
| } |
| h, replayedSeq, lastSnapshotSeq, err := s.replay(ctx, doc) |
| @@ -791,6 +800,12 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| // unconditionally truthful about what the handle holds. |
| if replayedSeq-lastSnapshotSeq >= snapshotCadence { |
| if err := s.writeSnapshot(ctx, doc, h, replayedSeq); err != nil { |
| + if s.team != nil { |
| + if errors.Is(err, ErrNotWritable) { |
| + return 0, err |
| + } |
| + return 0, errTeamUnavailable |
| + } |
| // Never fatal: the update below still stands, the log is |
| // untouched, and the next POST tries again — and so does |
| // every one after it, since the distance only grows until |
| @@ -841,6 +856,9 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| // by observing the number the closure already computed. |
| var assignedSeq int64 |
| if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite); err != nil { |
| + return err |
| + } |
| var maxSeq int64 |
| if err := tx.Model(&DocUpdate{}). |
| Where("doc_id = ?", doc.ID). |
| @@ -861,46 +879,15 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| if err := tx.Create(row).Error; err != nil { |
| return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err) |
| } |
| + |
| + if err := s.permissionScope(ctx, tx, actor, permissionWrite).Model(&Document{}).Where("documents.id = ?", doc.ID).Update("updated_at", time.Now().UTC()).Error; err != nil { |
| + return s.permissionError(ctx, actor, err) |
| + } |
| assignedSeq = row.Seq |
| - return nil |
| + return s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite) |
| }); err != nil { |
| return 0, err |
| } |
| - // Bumping documents.updated_at here — not just appending the |
| - // doc_updates row above — is what keeps Store.List's ordering and |
| - // handlers.go's groupByRecency/formatWhen honest about the same |
| - // clock. Skipping this was fix round 2's bug: with it skipped, |
| - // UpdatedAt only ever equalled CreatedAt (Store.Create sets both |
| - // to the same `now`), so List ordering by either column looked |
| - // identical and the divergence stayed invisible until something |
| - // — this method — actually moved UpdatedAt away from CreatedAt. |
| - // |
| - // Still routed through owned(doc.OwnerID), same as every other |
| - // write in this method now — ownership was already verified above |
| - // before replay ran, so by this point the row is guaranteed to |
| - // exist under doc.OwnerID and this can never match zero rows. It |
| - // stays scoped anyway rather than falling back to a bare |
| - // `Where("id = ?", doc.ID)`: SKILL.md §3, verbatim, "Scope the |
| - // write, not just the read that loaded the row," and a defense |
| - // that only holds because of a check earlier in the same function |
| - // is one accidental reordering away from not holding at all. |
| - if err := owned(s.db.WithContext(ctx), doc.OwnerID). |
| - Model(&Document{}). |
| - Where("id = ?", doc.ID). |
| - Update("updated_at", time.Now().UTC()).Error; err != nil { |
| - // The doc_updates row above is already committed at this |
| - // point, so this error is returned over durable content. That |
| - // is deliberate and its consequences are named in the design |
| - // (D4): the caller must not broadcast and must not report a |
| - // seq, the client's save queue treats the resulting 500 as |
| - // retryable, and the retry writes a duplicate row — which Yjs |
| - // integrates idempotently, costing one row against |
| - // maxRetainedUpdates rather than any content. Returning 0 here |
| - // rather than assignedSeq is what makes "seq is meaningful |
| - // only on a nil error" true by construction instead of by |
| - // convention. |
| - return 0, fmt.Errorf("docs: bump updated_at for document %d: %w", doc.ID, err) |
| - } |
| return assignedSeq, nil |
| } |
diff --git a/internal/docs/stream.go b/internal/docs/stream.go| index bd73b35..9469f18 100644 |
| --- a/internal/docs/stream.go |
| +++ b/internal/docs/stream.go |
| @@ -1,11 +1,13 @@ |
| package docs |
| import ( |
| + "context" |
| "io" |
| "net/http" |
| "strconv" |
| "time" |
| + "amadan.net/oficina/home/roster" |
| "github.com/carlosframework/rastrillo" |
| ) |
| @@ -67,7 +69,7 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) { |
| fl, ok := w.(http.Flusher) |
| if !ok { |
| h.logger.Error("docs: stream: ResponseWriter does not flush", "sqid", doc.Sqid) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| @@ -79,14 +81,55 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) { |
| ch, stop := h.hub.subscribe(doc.ID) |
| defer stop() |
| - current, err := h.store.currentSeq(r.Context(), doc.ID) |
| + var guard *teamStreamGuard |
| + var leaseTimer *time.Timer |
| + var leaseDeadline <-chan time.Time |
| + if h.store.team != nil { |
| + a, ok := r.Context().Value(accessContextKey{}).(*requestAccess) |
| + if !ok || a == nil || a.runtime != h.store.team { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + guard = &teamStreamGuard{store: h.store, access: a, request: r, docID: doc.ID} |
| + if a.decision.State == roster.Member { |
| + guard.deadline = time.Now().Add(a.decision.Lease.Remaining()) |
| + } |
| + if _, ok := guard.check(); !ok { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + if a.decision.State == roster.Member { |
| + remaining := guard.remaining() |
| + if remaining <= 0 { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + leaseTimer = time.NewTimer(remaining) |
| + defer leaseTimer.Stop() |
| + leaseDeadline = leaseTimer.C |
| + } |
| + } |
| + seqContext := r.Context() |
| + if guard != nil { |
| + var cancel context.CancelFunc |
| + seqContext, cancel = context.WithTimeout(seqContext, min(guard.remaining(), defaultStreamWriteDeadline)) |
| + defer cancel() |
| + } |
| + current, err := h.store.currentSeq(seqContext, doc.ID) |
| if err != nil { |
| h.logger.Error("docs: stream: read current seq", "sqid", doc.Sqid, "err", err) |
| - http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus()) |
| return |
| } |
| rc := http.NewResponseController(w) |
| + if guard != nil { |
| + limit, ok := guard.check() |
| + if !ok || rc.SetWriteDeadline(time.Now().Add(limit)) != nil { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + } |
| w.Header().Set("Content-Type", "text/event-stream") |
| w.Header().Set("Cache-Control", "no-store") |
| w.WriteHeader(http.StatusOK) |
| @@ -98,7 +141,17 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) { |
| // unbounded — while a write error ends the handler, because it |
| // means the client is gone. |
| write := func(text string) bool { |
| - _ = rc.SetWriteDeadline(time.Now().Add(defaultStreamWriteDeadline)) |
| + limit := defaultStreamWriteDeadline |
| + if guard != nil { |
| + var ok bool |
| + limit, ok = guard.check() |
| + if !ok { |
| + return false |
| + } |
| + } |
| + if err := rc.SetWriteDeadline(time.Now().Add(limit)); err != nil && guard != nil { |
| + return false |
| + } |
| if _, err := io.WriteString(w, text); err != nil { |
| return false |
| } |
| @@ -126,6 +179,8 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) { |
| for { |
| select { |
| + case <-leaseDeadline: |
| + return |
| case <-r.Context().Done(): |
| // The client went away, or the server is draining. Either |
| // way this handler must return so a graceful shutdown's |
diff --git a/internal/docs/team_access.go b/internal/docs/team_access.go| new file mode 100644 |
| index 0000000..6b409fb |
| --- /dev/null |
| +++ b/internal/docs/team_access.go |
| @@ -0,0 +1,253 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo" |
| + "gorm.io/gorm" |
| +) |
| + |
| +type accessContextKey struct{} |
| +type requestAccess struct { |
| + uncertain bool |
| + responseStore *Store |
| + responseDocs map[int64]permission |
| + runtime *teamRuntime |
| + actor Actor |
| + subject string |
| + decision roster.Decision |
| +} |
| +type permission uint8 |
| + |
| +const ( |
| + permissionRead permission = iota |
| + permissionWrite |
| + permissionOwner |
| +) |
| + |
| +var errTeamNotMember = errors.New("docs: team membership required") |
| + |
| +func sameActor(a, b Actor) bool { |
| + if a.PersonID != b.PersonID || (a.VerifiedAddress == nil) != (b.VerifiedAddress == nil) { |
| + return false |
| + } |
| + return a.VerifiedAddress == nil || *a.VerifiedAddress == *b.VerifiedAddress |
| +} |
| +func (a *requestAccess) state() roster.State { |
| + if a == nil { |
| + return roster.Unknown |
| + } |
| + if a.uncertain { |
| + return roster.Unknown |
| + } |
| + if a.decision.State == roster.Member && !a.decision.Lease.Valid() { |
| + a.uncertain = true |
| + return roster.Unknown |
| + } |
| + return a.decision.State |
| +} |
| +func (s *Store) access(ctx context.Context, actor Actor) (*requestAccess, bool) { |
| + a, ok := ctx.Value(accessContextKey{}).(*requestAccess) |
| + return a, ok && a != nil && a.runtime == s.team && sameActor(a.actor, actor) |
| +} |
| +func (t *teamRuntime) resolveAccess(ctx context.Context, id requestIdentity) *requestAccess { |
| + a := &requestAccess{runtime: t, actor: id.Actor, subject: id.HomeSubject, decision: roster.Decision{State: roster.NotMember}} |
| + if id.HomeSubject != "" { |
| + a.decision = t.cache.Check(ctx, t.bindings.Issuer, t.bindings.Team, id.HomeSubject) |
| + } |
| + return a |
| +} |
| +func (t *teamRuntime) admission(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + id, ok := currentIdentity(r) |
| + if !ok { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| + return |
| + } |
| + a := t.resolveAccess(r.Context(), id) |
| + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), accessContextKey{}, a))) |
| + }) |
| +} |
| + |
| +// permissionScope is also usable under the SQLite writer. It never refreshes |
| +// the roster, and checks a lease without waiting for a cache publisher. |
| +func (s *Store) permissionScope(ctx context.Context, g *gorm.DB, actor Actor, p permission) *gorm.DB { |
| + owner := s.team == nil |
| + if s.team != nil { |
| + a, ok := s.access(ctx, actor) |
| + if !ok { |
| + return g.Where("1 = 0") |
| + } |
| + owner = a.state() == roster.Member |
| + } |
| + if actor.PersonID <= 0 { |
| + return g.Where("1 = 0") |
| + } |
| + if p == permissionOwner { |
| + if !owner { |
| + return g.Where("1 = 0") |
| + } |
| + return owned(g, actor.PersonID) |
| + } |
| + grant := actor.VerifiedAddress != nil && *actor.VerifiedAddress != "" |
| + if !owner && !grant { |
| + return g.Where("1 = 0") |
| + } |
| + clause := "documents.owner_id > 0 AND (" |
| + args := []any{} |
| + if owner { |
| + clause += "documents.owner_id = ?" |
| + args = append(args, actor.PersonID) |
| + } |
| + if grant { |
| + if owner { |
| + clause += " OR " |
| + } |
| + clause += "EXISTS (SELECT 1 FROM grants WHERE grants.doc_id = documents.id AND grants.subject = ?" |
| + args = append(args, subjectOf(*actor.VerifiedAddress)) |
| + if p == permissionWrite { |
| + clause += " AND grants.role = ?" |
| + args = append(args, RoleWriter) |
| + } |
| + clause += ")" |
| + } |
| + return g.Where(clause+")", args...) |
| +} |
| +func (s *Store) permissionError(ctx context.Context, actor Actor, err error) error { |
| + if s.team == nil || err == nil { |
| + return err |
| + } |
| + if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + return errTeamUnavailable |
| + } |
| + a, ok := s.access(ctx, actor) |
| + if !ok || a.state() == roster.Unknown { |
| + return errTeamUnavailable |
| + } |
| + return gorm.ErrRecordNotFound |
| +} |
| +func (s *Store) requireMember(ctx context.Context, actor Actor) error { |
| + if s.team == nil { |
| + return nil |
| + } |
| + a, ok := s.access(ctx, actor) |
| + if !ok { |
| + return errTeamUnavailable |
| + } |
| + switch a.state() { |
| + case roster.Member: |
| + return nil |
| + case roster.NotMember: |
| + return errTeamNotMember |
| + default: |
| + return errTeamUnavailable |
| + } |
| +} |
| + |
| +func (s *Store) ownerActor(ctx context.Context, owner int64) Actor { |
| + if s.team == nil { |
| + return Actor{PersonID: owner} |
| + } |
| + a, ok := ctx.Value(accessContextKey{}).(*requestAccess) |
| + if !ok || a == nil || a.actor.PersonID != owner { |
| + return Actor{PersonID: owner} |
| + } |
| + return a.actor |
| +} |
| + |
| +func (s *Store) checkPermission(ctx context.Context, db *gorm.DB, actor Actor, docID int64, p permission) error { |
| + var doc Document |
| + err := s.permissionScope(ctx, db, actor, p).Where("documents.id = ? AND documents.deleted_at IS NULL", docID).First(&doc).Error |
| + err = s.permissionError(ctx, actor, err) |
| + if p == permissionWrite && errors.Is(err, gorm.ErrRecordNotFound) { |
| + return ErrNotWritable |
| + } |
| + return err |
| +} |
| +func (s *Store) snapshotPermission(ctx context.Context, db *gorm.DB, docID int64) error { |
| + if s.team == nil { |
| + return nil |
| + } |
| + a, ok := ctx.Value(accessContextKey{}).(*requestAccess) |
| + if !ok || a == nil { |
| + return errTeamUnavailable |
| + } |
| + return s.checkPermission(ctx, db, a.actor, docID, permissionWrite) |
| +} |
| + |
| +func (s *Store) ownerMutation(ctx context.Context, owner, docID int64, mutate func(*gorm.DB) error) error { |
| + actor := s.ownerActor(ctx, owner) |
| + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := s.checkPermission(ctx, tx, actor, docID, permissionOwner); err != nil { |
| + return err |
| + } |
| + if err := mutate(tx); err != nil { |
| + return s.permissionError(ctx, actor, err) |
| + } |
| + return s.checkPermission(ctx, tx, actor, docID, permissionOwner) |
| + }) |
| +} |
| + |
| +func (h *handlers) internalFailureStatus() int { |
| + if h.store.team != nil { |
| + return http.StatusServiceUnavailable |
| + } |
| + return http.StatusInternalServerError |
| +} |
| +func (h *handlers) requireMemberRequest(w http.ResponseWriter, r *http.Request, item bool) bool { |
| + if h.store.team == nil { |
| + if h.member(r) { |
| + return true |
| + } |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + return false |
| + } |
| + actor, e := h.actor(r) |
| + if e == nil { |
| + e = h.store.requireMember(r.Context(), actor) |
| + } |
| + if e == nil { |
| + return true |
| + } |
| + code := http.StatusServiceUnavailable |
| + key := "docs.error.generic" |
| + if errors.Is(e, errTeamNotMember) { |
| + code = http.StatusForbidden |
| + if item { |
| + code = http.StatusNotFound |
| + key = "docs.error.notfound" |
| + } |
| + } |
| + http.Error(w, rastrillo.T(r, key), code) |
| + return false |
| +} |
| + |
| +func (h *handlers) partialCatalog(r *http.Request) string { |
| + if h.store.team == nil { |
| + return "" |
| + } |
| + a, ok := r.Context().Value(accessContextKey{}).(*requestAccess) |
| + if !ok || a == nil || a.runtime != h.store.team || a.state() == roster.Unknown { |
| + return rastrillo.T(r, "docs.team.partial") |
| + } |
| + return "" |
| +} |
| + |
| +// The account group contains only local reauthentication and explicit linking. |
| +// A known addressless Home account must be able to start that local gesture. |
| +func requireTeamAccountAdmission(list allowlist) func(http.Handler) http.Handler { |
| + return func(next http.Handler) http.Handler { |
| + local := requireAdmission(list, "/signin/local")(next) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + id, ok := currentIdentity(r) |
| + if ok && id.Actor.VerifiedAddress == nil && id.Session.Method == homeAssertionMethod { |
| + next.ServeHTTP(w, r) |
| + return |
| + } |
| + local.ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
diff --git a/internal/docs/team_access_test.go b/internal/docs/team_access_test.go| new file mode 100644 |
| index 0000000..af6b6db |
| --- /dev/null |
| +++ b/internal/docs/team_access_test.go |
| @@ -0,0 +1,454 @@ |
| +package docs |
| + |
| +import ( |
| + "amadan.net/oficina/home/handoff/seal" |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + "gorm.io/gorm" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestTeamPermissionFormerOwnerOnlyReachesExactGrant(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := context.Background() |
| + owner, e := s.EnsurePerson(ctx, "former@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + other, e := s.EnsurePerson(ctx, "other@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + private, e := s.Create(ctx, owner, "formerly owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + shared, e := s.Create(ctx, other, "individual grant") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + stranger, e := s.Create(ctx, other, "other private") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = s.Grant(ctx, other, shared.Sqid, "former@example.com", RoleWriter); e != nil { |
| + t.Fatal(e) |
| + } |
| + s.team = &teamRuntime{} |
| + actor := localActor(owner, "former@example.com") |
| + for _, state := range []roster.State{roster.NotMember, roster.Unknown} { |
| + a := &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: state}} |
| + c := context.WithValue(ctx, accessContextKey{}, a) |
| + for _, mode := range []permission{permissionRead, permissionWrite, permissionOwner} { |
| + for _, doc := range []*Document{private, shared, stranger} { |
| + var count int64 |
| + e := s.permissionScope(c, s.db.Model(&Document{}), actor, mode).Where("documents.id = ?", doc.ID).Count(&count).Error |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + want := int64(0) |
| + if doc == shared && mode != permissionOwner { |
| + want = 1 |
| + } |
| + if count != want { |
| + t.Fatalf("state %v mode %v document %v: got %d want %d", state, mode, doc.ID, count, want) |
| + } |
| + } |
| + } |
| + |
| + for _, sqid := range []string{private.Sqid, stranger.Sqid, "nonexistent"} { |
| + _, e := s.GetFor(c, actor, sqid) |
| + if state == roster.Unknown && !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("unknown item must be retryable", e) |
| + } |
| + if state == roster.NotMember && !errors.Is(e, gorm.ErrRecordNotFound) { |
| + t.Fatal("removed owner reached private item", e) |
| + } |
| + } |
| + got, e := s.ListFor(c, actor) |
| + if e != nil || len(got) != 1 || got[0].ID != shared.ID { |
| + t.Fatal("list escaped individual grant", e) |
| + } |
| + if _, e = s.GetFor(c, actor, shared.Sqid); e != nil { |
| + t.Fatal("independent grant refused", e) |
| + } |
| + if _, e = s.Get(c, owner, private.Sqid); e == nil { |
| + t.Fatal("owner administration escaped membership") |
| + } |
| + |
| + e = s.permissionError(c, actor, gorm.ErrRecordNotFound) |
| + if state == roster.Unknown && !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("uncertain absence was not retryable") |
| + } |
| + if state == roster.NotMember && !errors.Is(e, gorm.ErrRecordNotFound) { |
| + t.Fatal("known refusal was not definite") |
| + } |
| + if e = s.requireMember(c, actor); e == nil { |
| + t.Fatal("grant became membership") |
| + } |
| + } |
| + for _, c := range []context.Context{ctx, context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: &teamRuntime{}, actor: actor})} { |
| + var n int64 |
| + if e = s.permissionScope(c, s.db.Model(&Document{}), actor, permissionRead).Count(&n).Error; e != nil || n != 0 { |
| + t.Fatal("missing or foreign access context was accepted") |
| + } |
| + } |
| + c := context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: roster.NotMember}}) |
| + var n int64 |
| + if e = s.permissionScope(c, s.db.Model(&Document{}), localActor(other, "former@example.com"), permissionRead).Count(&n).Error; e != nil || n != 0 { |
| + t.Fatal("actor mismatch accepted") |
| + } |
| +} |
| + |
| +type teamTestClock struct { |
| + next func() |
| + mu sync.Mutex |
| + elapsed time.Duration |
| +} |
| + |
| +func (c *teamTestClock) Now() (time.Time, time.Duration) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if c.next != nil { |
| + fn := c.next |
| + c.next = nil |
| + fn() |
| + } |
| + return time.Unix(1800000000, 0).Add(c.elapsed), c.elapsed |
| +} |
| +func (c *teamTestClock) step(d time.Duration) { c.mu.Lock(); defer c.mu.Unlock(); c.elapsed += d } |
| + |
| +type teamTestTransport func(*http.Request) (*http.Response, error) |
| + |
| +func (f teamTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| + |
| +func testTeamRoster(t *testing.T, s *Store, app *App) (*teamRuntime, *teamTestClock, string) { |
| + t.Helper() |
| + op, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + key, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + fixed := func(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 32)) } |
| + b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"} |
| + p := roster.Provisioning{Version: 1, Operation: "provision1", Sequence: 1, Bindings: b, Generation: fixed(1), MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(key.SignPub())}}} |
| + raw, e := roster.SignProvisioning(op, "operator1", p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + record, e := roster.VerifyProvisioning(raw, []roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, b) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + sealer, e := seal.New(bytes.Repeat([]byte{9}, 32), "team-test") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + clock := &teamTestClock{} |
| + subject := "pr_" + strings.Repeat("a", 26) |
| + transport := teamTestTransport(func(r *http.Request) (*http.Response, error) { |
| + _, elapsed := clock.Now() |
| + if elapsed >= 30*time.Second { |
| + return nil, errors.New("directory unavailable") |
| + } |
| + i, complete := 0, true |
| + members := []roster.MemberEntry{{Subject: subject, Role: "member"}} |
| + page := roster.Page{Version: 1, Type: "page", Directory: b.Directory, Consumer: b.Consumer, Team: b.Team, Audience: b.Audience, Fetch: r.URL.Query().Get("fetch"), RequestCursorHash: roster.CursorHash(r.URL.Query().Get("cursor")), Generation: p.Generation, Epoch: 1, Index: &i, Members: &members, Complete: &complete} |
| + raw, e := roster.SignPage(key, "roster1", page) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil |
| + }) |
| + cache, e := roster.New(t.Context(), roster.Config{Record: record, Token: fixed(7), Clock: clock, Sealer: sealer, Durable: teamstate.Evidence{DB: app.db.Writer()}, Transport: transport}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + ps, e := principalstore.New(app.db.Writer(), principalstore.Config{Issuer: b.Issuer, Team: b.Team}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt := &teamRuntime{cache: cache, bindings: b, db: app.db.Writer(), principals: ps} |
| + s.team = rt |
| + return rt, clock, subject |
| +} |
| + |
| +func TestTeamPermissionMemberLeaseEndsAtSixtySeconds(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + owner, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, owner, "owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, clock, subject := testTeamRoster(t, s, app) |
| + actor := localActor(owner, "owner@example.com") |
| + access := rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject}) |
| + if access.state() != roster.Member { |
| + t.Fatal("signed member refused") |
| + } |
| + ctx = context.WithValue(ctx, accessContextKey{}, access) |
| + check := func(want int64) { |
| + t.Helper() |
| + var n int64 |
| + e := s.permissionScope(ctx, s.db.Model(&Document{}), actor, permissionOwner).Where("id = ?", doc.ID).Count(&n).Error |
| + if e != nil || n != want { |
| + t.Fatalf("owner access count %d want %d: %v", n, want, e) |
| + } |
| + } |
| + check(1) |
| + clock.step(30 * time.Second) |
| + if e = rt.cache.Refresh(ctx); e == nil { |
| + t.Fatal("failed refresh accepted") |
| + } |
| + check(1) |
| + clock.step(30 * time.Second) |
| + check(0) |
| + if e = s.requireMember(ctx, actor); !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("expired member edit must retry") |
| + } |
| +} |
| + |
| +func TestTeamWriterGrantRevokedWhileWaitingForSQLiteWriter(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + owner, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + guest, e := s.EnsurePerson(ctx, "guest@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, owner, "shared") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = s.Grant(ctx, owner, doc.Sqid, "guest@example.com", RoleWriter); e != nil { |
| + t.Fatal(e) |
| + } |
| + s.team = &teamRuntime{} |
| + actor := localActor(guest, "guest@example.com") |
| + ctx = context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: roster.NotMember}}) |
| + writer := app.db.Writer() |
| + tx, e := writer.BeginTx(ctx, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + before := writer.Stats().WaitCount |
| + done := make(chan error, 1) |
| + update := validUpdate(t) |
| + go func() { _, e := s.ApplyUpdate(ctx, doc, actor, update); done <- e }() |
| + deadline := time.Now().Add(3 * time.Second) |
| + for writer.Stats().WaitCount == before && time.Now().Before(deadline) { |
| + select { |
| + case e := <-done: |
| + t.Fatalf("update did not reach writer queue: %v", e) |
| + default: |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| + if writer.Stats().WaitCount == before { |
| + t.Fatal("update did not wait for actual SQLite writer") |
| + } |
| + if _, e = tx.ExecContext(ctx, "DELETE FROM grants WHERE doc_id=? AND subject=?", doc.ID, "guest@example.com"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + select { |
| + case e = <-done: |
| + if !errors.Is(e, ErrNotWritable) { |
| + t.Fatal("revoked writer did not receive definite refusal", e) |
| + } |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("writer deadlock") |
| + } |
| + var n int64 |
| + if e = s.db.Model(&DocUpdate{}).Where("doc_id=?", doc.ID).Count(&n).Error; e != nil || n != 0 { |
| + t.Fatal("revoked write persisted", e) |
| + } |
| + var current Document |
| + if e = s.db.First(¤t, doc.ID).Error; e != nil || !current.UpdatedAt.Equal(doc.UpdatedAt) { |
| + t.Fatal("revoked write changed timestamp", e) |
| + } |
| +} |
| + |
| +func TestTeamLeaseExpiresAfterInsertBeforeCommit(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + owner, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, owner, "owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, clock, subject := testTeamRoster(t, s, app) |
| + actor := localActor(owner, "owner@example.com") |
| + ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject})) |
| + fired := false |
| + if e = s.db.Callback().Create().After("gorm:create").Register("test:expire-team-lease", func(tx *gorm.DB) { |
| + if tx.Statement.Table == "doc_updates" { |
| + fired = true |
| + clock.step(60 * time.Second) |
| + } |
| + }); e != nil { |
| + t.Fatal(e) |
| + } |
| + defer s.db.Callback().Create().Remove("test:expire-team-lease") |
| + seq, e := s.ApplyUpdate(ctx, doc, actor, validUpdate(t)) |
| + if !fired || seq != 0 || !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("final expiry did not refuse commit", seq, e) |
| + } |
| + var n int64 |
| + if e = s.db.Model(&DocUpdate{}).Where("doc_id=?", doc.ID).Count(&n).Error; e != nil || n != 0 { |
| + t.Fatal("expired update committed", e) |
| + } |
| + var current Document |
| + if e = s.db.First(¤t, doc.ID).Error; e != nil || !current.UpdatedAt.Equal(doc.UpdatedAt) { |
| + t.Fatal("expired update changed timestamp", e) |
| + } |
| +} |
| + |
| +func TestTeamMutationLeaseExpiryRollsBackEveryTable(t *testing.T) { |
| + for _, table := range []string{"documents", "grants", "links", "doc_snapshots"} { |
| + t.Run(table, func(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + owner, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, owner, "owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, clock, subject := testTeamRoster(t, s, app) |
| + actor := localActor(owner, "owner@example.com") |
| + ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject})) |
| + var before int64 |
| + if e = s.db.Table(table).Count(&before).Error; e != nil { |
| + t.Fatal(e) |
| + } |
| + fired := false |
| + if e = s.db.Callback().Create().After("gorm:create").Register("test:expire-admin", func(tx *gorm.DB) { |
| + if tx.Statement.Table == table { |
| + fired = true |
| + clock.step(60 * time.Second) |
| + } |
| + }); e != nil { |
| + t.Fatal(e) |
| + } |
| + defer s.db.Callback().Create().Remove("test:expire-admin") |
| + switch table { |
| + case "documents": |
| + _, e = s.Create(ctx, owner, "refused") |
| + case "grants": |
| + e = s.Grant(ctx, owner, doc.Sqid, "guest@example.com", RoleReader) |
| + case "links": |
| + _, e = s.Publish(ctx, owner, doc.Sqid) |
| + case "doc_snapshots": |
| + h, seq, _, err := s.replay(ctx, doc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Close(ctx) |
| + e = s.writeSnapshot(ctx, doc, h, seq) |
| + } |
| + if !fired || !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("mutation survived final lease expiry", e) |
| + } |
| + var after int64 |
| + if e = s.db.Table(table).Count(&after).Error; e != nil || after != before { |
| + t.Fatal("mutation committed after lease expiry", e) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestTeamPermissionContentionCannotRecoverIntoPermanentDenial(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + b := f.browser() |
| + b.localSignin(t, "alice@example.com") |
| + b.linkProof(t, teamSubject('a'), "alice@example.com") |
| + routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/") |
| + session, ok := f.sessions.From(b.request("GET", "/", "")) |
| + if !ok { |
| + t.Fatal("session") |
| + } |
| + request := sessions.WithSession(b.request("GET", "/", ""), session) |
| + id, e := f.app.store.team.bridge.resolver.resolve(request) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + a := f.app.store.team.resolveAccess(t.Context(), id) |
| + if a.state() != roster.Member { |
| + t.Fatal("member unavailable") |
| + } |
| + ctx := context.WithValue(t.Context(), accessContextKey{}, a) |
| + entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) |
| + f.clock.mu.Lock() |
| + f.clock.next = func() { close(entered); <-release } |
| + f.clock.mu.Unlock() |
| + go func() { |
| + f.app.store.team.cache.Check(t.Context(), bridgeIssuer, "TeamA", teamSubject('a')) |
| + close(finished) |
| + }() |
| + <-entered |
| + // Cache.Check holds the real shared cache mutex while reading this clock. |
| + scope := f.app.store.permissionScope(ctx, f.app.store.db.Model(&Document{}), id.Actor, permissionWrite) |
| + close(release) |
| + <-finished |
| + if !a.decision.Lease.Valid() { |
| + t.Fatal("fixture did not recover the same valid lease") |
| + } |
| + var found Document |
| + e = scope.Where("id = ?", f.own.ID).First(&found).Error |
| + if !errors.Is(e, gorm.ErrRecordNotFound) { |
| + t.Fatal("contended ownership scope did not refuse", e) |
| + } |
| + if e = f.app.store.permissionError(ctx, id.Actor, e); !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("transient ownership refusal became permanent after lease recovered", e) |
| + } |
| + if e = f.app.store.checkPermission(ctx, f.app.store.db, id.Actor, f.shared.ID, permissionWrite); e != nil { |
| + t.Fatal("sticky uncertainty revoked an independent writer grant", e) |
| + } |
| + if a.state() != roster.Unknown { |
| + t.Fatal("request regained uncertain ownership standing") |
| + } |
| + fresh := f.app.store.team.resolveAccess(t.Context(), id) |
| + if fresh.state() != roster.Member { |
| + t.Fatal("uncertainty leaked into a new request") |
| + } |
| +} |
diff --git a/internal/docs/team_config.go b/internal/docs/team_config.go| new file mode 100644 |
| index 0000000..4df74a3 |
| --- /dev/null |
| +++ b/internal/docs/team_config.go |
| @@ -0,0 +1,124 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/base64" |
| + "errors" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/handoff/seal" |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +// TeamConfig is explicit deployment configuration, never browser request input. |
| +type TeamConfig struct { |
| + transport http.RoundTripper |
| + clock roster.Clock |
| + TeamID string |
| + DirectoryOrigin string |
| + ProvisioningBase64 string |
| + OperatorKeysBase64 string |
| + RosterToken string |
| +} |
| + |
| +var errTeamConfig = errors.New("docs: invalid team configuration") |
| +var errTeamUnavailable = errors.New("docs: team authority unavailable") |
| + |
| +type teamSettings struct { |
| + transport http.RoundTripper |
| + clock roster.Clock |
| + record roster.VerifiedRecord |
| + token string |
| + fingerprint string |
| +} |
| + |
| +var TeamBootSchema = migrate.Merge(BootSchema, principalstore.Schema, teamstate.Schema) |
| + |
| +func validatedTeamConfig(cfg *TeamConfig, home *homeSettings, origin string) (*teamSettings, error) { |
| + if cfg == nil { |
| + return nil, nil |
| + } |
| + if home == nil || !roster.ValidChallenge(cfg.RosterToken) { |
| + return nil, errTeamConfig |
| + } |
| + raw, ok := homeConfigBase64(cfg.ProvisioningBase64, roster.BodyLimit) |
| + if !ok { |
| + return nil, errTeamConfig |
| + } |
| + encoded, ok := homeConfigBase64(cfg.OperatorKeysBase64, 4096) |
| + if !ok { |
| + return nil, errTeamConfig |
| + } |
| + var keys []roster.Key |
| + if json.Unmarshal(encoded, &keys, json.RejectUnknownMembers(true)) != nil { |
| + return nil, errTeamConfig |
| + } |
| + expected := roster.Bindings{Issuer: home.issuer, Directory: cfg.DirectoryOrigin, Consumer: home.consumerID, App: "docs", Team: cfg.TeamID, Audience: origin} |
| + record, e := roster.VerifyProvisioning(raw, keys, expected) |
| + if e != nil { |
| + return nil, errTeamConfig |
| + } |
| + b, e := json.Marshal(record.Bindings()) |
| + if e != nil { |
| + return nil, errTeamConfig |
| + } |
| + digest := sha256.Sum256(b) |
| + return &teamSettings{transport: cfg.transport, clock: cfg.clock, record: record, token: cfg.RosterToken, fingerprint: base64.RawURLEncoding.EncodeToString(digest[:])}, nil |
| +} |
| + |
| +type teamRuntime struct { |
| + bridge *homeBridge |
| + readSession func(context.Context, *http.Request) (sessions.Session, bool) |
| + cache *roster.Cache |
| + principals *principalstore.Store |
| + bindings roster.Bindings |
| + db *sql.DB |
| +} |
| + |
| +func newTeamRuntime(ctx context.Context, settings *teamSettings, db *sql.DB, home *homeSettings) (*teamRuntime, error) { |
| + if settings == nil || db == nil || home == nil { |
| + return nil, errTeamConfig |
| + } |
| + b := settings.record.Bindings() |
| + var foreign int |
| + if e := db.QueryRowContext(ctx, "SELECT (SELECT COUNT(*) FROM docs_identity_links WHERE issuer!=?)+(SELECT COUNT(*) FROM docs_home_identities WHERE issuer!=?)", b.Issuer, b.Issuer).Scan(&foreign); e != nil { |
| + return nil, errTeamUnavailable |
| + } |
| + if foreign != 0 { |
| + return nil, errTeamConfig |
| + } |
| + if e := teamstate.Activate(ctx, db, settings.fingerprint); e != nil { |
| + return nil, errTeamConfig |
| + } |
| + sealer, e := seal.New(home.dek, "oficina/docs") |
| + if e != nil { |
| + return nil, errTeamConfig |
| + } |
| + cache, e := roster.New(ctx, roster.Config{Record: settings.record, Token: settings.token, Transport: settings.transport, Clock: settings.clock, Durable: teamstate.Evidence{DB: db}, Sealer: sealer}) |
| + if e != nil { |
| + return nil, errTeamUnavailable |
| + } |
| + runtime := &teamRuntime{cache: cache, bindings: b, db: db} |
| + principals, e := principalstore.New(db, principalstore.Config{Issuer: b.Issuer, Team: b.Team, RequireMembership: func(ctx context.Context, request principalstore.MembershipRequest) error { |
| + d := cache.Check(ctx, request.Issuer, request.Team, request.Subject) |
| + if d.State == roster.Member && d.Lease.Valid() { |
| + return nil |
| + } |
| + if d.State == roster.NotMember { |
| + return principalstore.ErrRefused |
| + } |
| + return principalstore.ErrUnavailable |
| + }}) |
| + if e != nil { |
| + return nil, errTeamUnavailable |
| + } |
| + runtime.principals = principals |
| + return runtime, nil |
| +} |
diff --git a/internal/docs/team_emission.go b/internal/docs/team_emission.go| new file mode 100644 |
| index 0000000..e7f209d |
| --- /dev/null |
| +++ b/internal/docs/team_emission.go |
| @@ -0,0 +1,130 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "strconv" |
| + "time" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo" |
| + "gorm.io/gorm" |
| +) |
| + |
| +func (s *Store) rememberResponse(ctx context.Context, p permission, docs ...*Document) { |
| + if s.team == nil { |
| + return |
| + } |
| + a, ok := ctx.Value(accessContextKey{}).(*requestAccess) |
| + if !ok || a == nil || a.runtime != s.team { |
| + return |
| + } |
| + if a.responseDocs == nil { |
| + a.responseDocs = map[int64]permission{} |
| + } |
| + a.responseStore = s |
| + for _, doc := range docs { |
| + if prior, ok := a.responseDocs[doc.ID]; !ok || p > prior { |
| + a.responseDocs[doc.ID] = p |
| + } |
| + } |
| +} |
| +func (a *requestAccess) checkResponse(ctx context.Context) error { |
| + if a.responseStore == nil { |
| + return errTeamUnavailable |
| + } |
| + if len(a.responseDocs) == 0 && a.state() == roster.Unknown { |
| + return errTeamUnavailable |
| + } |
| + for id, p := range a.responseDocs { |
| + if e := a.responseStore.checkPermission(ctx, a.responseStore.db.WithContext(ctx), a.actor, id, p); e != nil { |
| + return e |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// writeGuardedBody checks each bounded chunk after rendering has completed. |
| +// Once headers are sent, refusal must abort the transport rather than produce |
| +// a successful short body with a sequence number for bytes never received. |
| +func writeGuardedBody(w http.ResponseWriter, r *http.Request, status int, parts ...[]byte) error { |
| + a, team := r.Context().Value(accessContextKey{}).(*requestAccess) |
| + if !team || a == nil { |
| + if status != 0 { |
| + w.WriteHeader(status) |
| + } |
| + for _, part := range parts { |
| + if _, e := w.Write(part); e != nil { |
| + return e |
| + } |
| + } |
| + return nil |
| + } |
| + fail := func(e error) { |
| + w.Header().Del("Docs-Seq") |
| + w.Header().Del("Content-Disposition") |
| + w.Header().Del("Content-Length") |
| + code := http.StatusServiceUnavailable |
| + key := "docs.error.generic" |
| + if errors.Is(e, gorm.ErrRecordNotFound) { |
| + code = http.StatusNotFound |
| + key = "docs.error.notfound" |
| + } |
| + http.Error(w, rastrillo.T(r, key), code) |
| + } |
| + controller := http.NewResponseController(w) |
| + prepare := func() error { |
| + remaining := defaultStreamWriteDeadline |
| + if a.state() == roster.Member { |
| + lease := a.decision.Lease.Remaining() |
| + if lease <= 0 { |
| + return errTeamUnavailable |
| + } |
| + if lease < remaining { |
| + remaining = lease |
| + } |
| + } |
| + deadline := time.Now().Add(remaining) |
| + ctx, cancel := context.WithDeadline(r.Context(), deadline) |
| + defer cancel() |
| + if e := a.checkResponse(ctx); e != nil { |
| + return e |
| + } |
| + if ctx.Err() != nil { |
| + return errTeamUnavailable |
| + } |
| + if e := controller.SetWriteDeadline(deadline); e != nil { |
| + return errTeamUnavailable |
| + } |
| + return nil |
| + } |
| + if e := prepare(); e != nil { |
| + fail(e) |
| + return nil |
| + } |
| + total := 0 |
| + for _, part := range parts { |
| + total += len(part) |
| + } |
| + w.Header().Set("Content-Length", strconv.Itoa(total)) |
| + if status == 0 { |
| + status = http.StatusOK |
| + } |
| + w.WriteHeader(status) |
| + defer controller.SetWriteDeadline(time.Time{}) |
| + for _, part := range parts { |
| + for len(part) > 0 { |
| + if e := prepare(); e != nil { |
| + panic(http.ErrAbortHandler) |
| + } |
| + n := min(len(part), 64<<10) |
| + wrote, e := w.Write(part[:n]) |
| + if e != nil || wrote != n { |
| + panic(http.ErrAbortHandler) |
| + } |
| + part = part[n:] |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/team_emission_test.go b/internal/docs/team_emission_test.go| new file mode 100644 |
| index 0000000..17f313a |
| --- /dev/null |
| +++ b/internal/docs/team_emission_test.go |
| @@ -0,0 +1,82 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "strconv" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type teamDeadlineRecorder struct { |
| + *httptest.ResponseRecorder |
| + afterWrite func() |
| +} |
| + |
| +func (w *teamDeadlineRecorder) SetWriteDeadline(time.Time) error { return nil } |
| +func (w *teamDeadlineRecorder) Write(b []byte) (int, error) { |
| + n, e := w.ResponseRecorder.Write(b) |
| + if w.afterWrite != nil { |
| + f := w.afterWrite |
| + w.afterWrite = nil |
| + f() |
| + } |
| + return n, e |
| +} |
| +func TestTeamBufferedEmissionRefusesLateAndAbortsPartial(t *testing.T) { |
| + for _, late := range []bool{false, true} { |
| + t.Run(strconv.FormatBool(late), func(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + owner, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, owner, "owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, clock, subject := testTeamRoster(t, s, app) |
| + actor := localActor(owner, "owner@example.com") |
| + ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject})) |
| + if _, e = s.GetFor(ctx, actor, doc.Sqid); e != nil { |
| + t.Fatal(e) |
| + } |
| + r := httptest.NewRequest("GET", "https://docs.example/d/"+doc.Sqid, nil).WithContext(ctx) |
| + w := &teamDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()} |
| + w.Header().Set("Docs-Seq", "50") |
| + body := bytes.Repeat([]byte{'x'}, 3*64<<10) |
| + if late { |
| + clock.step(60 * time.Second) |
| + } else { |
| + w.afterWrite = func() { clock.step(60 * time.Second) } |
| + } |
| + aborted := false |
| + func() { |
| + defer func() { |
| + if v := recover(); v != nil { |
| + if v != http.ErrAbortHandler { |
| + panic(v) |
| + } |
| + aborted = true |
| + } |
| + }() |
| + if e = writeGuardedBody(w, r, 0, body); e != nil { |
| + t.Fatal(e) |
| + } |
| + }() |
| + if late { |
| + if aborted || w.Code != 503 || w.Header().Get("Docs-Seq") != "" || bytes.Contains(w.Body.Bytes(), body[:64]) { |
| + t.Fatal("late refusal emitted private bytes or sequence") |
| + } |
| + } else { |
| + if !aborted || w.Code != 200 || w.Body.Len() != 64<<10 || w.Header().Get("Content-Length") != strconv.Itoa(len(body)) { |
| + t.Fatal("partial body was not explicitly aborted with exact length") |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/docs/team_home.go b/internal/docs/team_home.go| new file mode 100644 |
| index 0000000..e51efcb |
| --- /dev/null |
| +++ b/internal/docs/team_home.go |
| @@ -0,0 +1,62 @@ |
| +package docs |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +// existingPrincipal is read-only; an existing browser session cannot cause an |
| +// unknown incoming identity to allocate a second account during completion. |
| +func (b *homeBridge) existingPrincipal(r *http.Request, session sessions.Session) (principalstore.Principal, error) { |
| + t := b.store.team |
| + if t == nil { |
| + return principalstore.Principal{}, errIdentityRefused |
| + } |
| + if session.Method == homeAssertionMethod { |
| + return t.principals.Resolve(r.Context(), t.bindings.Issuer, session.Subject) |
| + } |
| + person, e := b.existingPerson(r, session) |
| + if e != nil { |
| + return principalstore.Principal{}, e |
| + } |
| + p, e := t.principals.Person(r.Context(), person.ID) |
| + if e == nil && p.Kind != principalstore.Local { |
| + return principalstore.Principal{}, errIdentityRefused |
| + } |
| + return p, e |
| +} |
| +func (b *homeBridge) completeTeamPrincipal(r *http.Request, claims assertion.Claims, current *sessions.Session) error { |
| + t := b.store.team |
| + p, e := t.principals.Resolve(r.Context(), claims.Issuer, claims.Subject) |
| + if e != nil && !errors.Is(e, principalstore.ErrRefused) { |
| + return rejectHome(r, "failed", http.StatusServiceUnavailable) |
| + } |
| + if current != nil { |
| + if e != nil { |
| + return rejectHome(r, "failed", http.StatusForbidden) |
| + } |
| + existing, err := b.existingPrincipal(r, *current) |
| + if errors.Is(err, principalstore.ErrUnavailable) || errors.Is(err, errIdentityStorage) { |
| + return rejectHome(r, "failed", http.StatusServiceUnavailable) |
| + } |
| + if err != nil || existing.ID != p.ID { |
| + return rejectHome(r, "failed", http.StatusForbidden) |
| + } |
| + return nil |
| + } |
| + if e == nil { |
| + return nil |
| + } |
| + _, e = t.principals.EnsureHome(r.Context(), principalstore.HomeIdentity{Issuer: claims.Issuer, Subject: claims.Subject}) |
| + if errors.Is(e, principalstore.ErrRefused) { |
| + return rejectHome(r, "unknown", http.StatusForbidden) |
| + } |
| + if e != nil { |
| + return rejectHome(r, "failed", http.StatusServiceUnavailable) |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/team_home_test.go b/internal/docs/team_home_test.go| new file mode 100644 |
| index 0000000..2cbf266 |
| --- /dev/null |
| +++ b/internal/docs/team_home_test.go |
| @@ -0,0 +1,79 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +func TestTeamCompletionCannotAllocateAroundCurrentAccount(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + local, e := s.EnsurePerson(ctx, "local@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = s.Create(ctx, local, "existing"); e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, clock, subject := testTeamRoster(t, s, app) |
| + calls := 0 |
| + rt.principals, e = principalstore.New(app.db.Writer(), principalstore.Config{Issuer: rt.bindings.Issuer, Team: rt.bindings.Team, RequireMembership: func(ctx context.Context, q principalstore.MembershipRequest) error { |
| + calls++ |
| + d := rt.cache.Check(ctx, q.Issuer, q.Team, q.Subject) |
| + if d.State == roster.Member && d.Lease.Valid() { |
| + return nil |
| + } |
| + if d.State == roster.NotMember { |
| + return principalstore.ErrRefused |
| + } |
| + return principalstore.ErrUnavailable |
| + }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b := &homeBridge{store: s, resolver: identityResolver{store: s, team: rt}} |
| + result := &homeFinishResult{} |
| + r := httptest.NewRequest("GET", "https://docs.example/auth/home/finish", nil).WithContext(context.WithValue(ctx, homeFinishKey{}, result)) |
| + claims := assertion.Claims{Issuer: rt.bindings.Issuer, Subject: subject} |
| + current := &sessions.Session{Subject: "local@example.com", Method: "magiclink", At: time.Now()} |
| + if e = b.completeTeamPrincipal(r, claims, current); e == nil || calls != 0 { |
| + t.Fatal("current account conflict reached allocation", e) |
| + } |
| + if _, e = rt.principals.Resolve(ctx, claims.Issuer, subject); !errors.Is(e, principalstore.ErrRefused) { |
| + t.Fatal("conflict allocated principal", e) |
| + } |
| + if e = b.completeTeamPrincipal(r, claims, nil); e != nil || calls != 1 { |
| + t.Fatal("verified fresh member could not create stable identity", e) |
| + } |
| + p, e := rt.principals.Resolve(ctx, claims.Issuer, subject) |
| + if e != nil || p.ID != principalstore.HomeIDMin || p.VerifiedAddress != nil { |
| + t.Fatal("Home principal changed address or allocation", e) |
| + } |
| + var people, docs int64 |
| + s.db.Model(&Person{}).Count(&people) |
| + s.db.Model(&Document{}).Count(&docs) |
| + if people != 1 || docs != 1 { |
| + t.Fatal("Home completion fabricated local person or sample documents") |
| + } |
| + clock.step(60 * time.Second) |
| + if e = b.completeTeamPrincipal(r, claims, &sessions.Session{Subject: subject, Method: homeAssertionMethod}); e != nil || calls != 1 { |
| + t.Fatal("known identity requires no new allocation or standing", e) |
| + } |
| + claims.Subject = "pr_" + strings.Repeat("b", 26) |
| + if e = b.completeTeamPrincipal(r, claims, nil); e == nil || result.status != 503 { |
| + t.Fatal("unavailable roster did not refuse unknown identity", e) |
| + } |
| + if _, e = rt.principals.Resolve(ctx, claims.Issuer, claims.Subject); !errors.Is(e, principalstore.ErrRefused) { |
| + t.Fatal("unknown roster allocated principal", e) |
| + } |
| +} |
diff --git a/internal/docs/team_identity_test.go b/internal/docs/team_identity_test.go| new file mode 100644 |
| index 0000000..1d37662 |
| --- /dev/null |
| +++ b/internal/docs/team_identity_test.go |
| @@ -0,0 +1,135 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/go-json-experiment/json" |
| + "gorm.io/gorm" |
| +) |
| + |
| +func teamConfigFixture(t *testing.T) (*TeamConfig, *homeSettings) { |
| + t.Helper() |
| + op, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + key, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"} |
| + p := roster.Provisioning{Version: 1, Operation: "provision1", Sequence: 1, Bindings: b, Generation: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)), MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(key.SignPub())}}} |
| + raw, e := roster.SignProvisioning(op, "operator1", p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}) |
| + return &TeamConfig{TeamID: b.Team, DirectoryOrigin: b.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(raw), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}, &homeSettings{issuer: b.Issuer, consumerID: b.Consumer, dek: bytes.Repeat([]byte{9}, 32)} |
| +} |
| +func TestTeamConfigPinsAndColdMarker(t *testing.T) { |
| + cfg, home := teamConfigFixture(t) |
| + settings, e := validatedTeamConfig(cfg, home, "https://docs.example") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, change := range []func(*TeamConfig){func(c *TeamConfig) { c.TeamID = "Other" }, func(c *TeamConfig) { c.DirectoryOrigin = "https://other.example" }, func(c *TeamConfig) { c.RosterToken += "=" }, func(c *TeamConfig) { c.ProvisioningBase64 += "\n" }, func(c *TeamConfig) { c.OperatorKeysBase64 = "e30=" }} { |
| + bad := *cfg |
| + change(&bad) |
| + if _, e := validatedTeamConfig(&bad, home, "https://docs.example"); e != errTeamConfig { |
| + t.Fatal("bad team config") |
| + } |
| + } |
| + if _, e := validatedTeamConfig(cfg, nil, "https://docs.example"); e != errTeamConfig { |
| + t.Fatal("team without Home") |
| + } |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + if _, e := migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, e := newTeamRuntime(t.Context(), settings, app.db.Writer(), home) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if rt.cache == nil || rt.principals == nil { |
| + t.Fatal("runtime missing") |
| + } |
| + if got, yes, e := teamstate.Required(t.Context(), app.db.Writer()); e != nil || !yes || got != settings.fingerprint { |
| + t.Fatal("runtime did not commit marker") |
| + } |
| + var count int |
| + _ = app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_home_identities").Scan(&count) |
| + if count != 0 { |
| + t.Fatal("runtime seeded identity") |
| + } |
| +} |
| +func TestTeamIdentityAddressAbsenceNeverRedeemsEmptyGrant(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + ctx := context.Background() |
| + s := app.Store() |
| + local, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, local, "private") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = migrate.Apply(ctx, app.db, TeamBootSchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + ps, e := principalstore.New(app.db.Writer(), principalstore.Config{Issuer: "https://id.example", Team: "TeamA", RequireMembership: func(context.Context, principalstore.MembershipRequest) error { return nil }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + subject := "pr_" + strings.Repeat("a", 26) |
| + p, e := ps.EnsureHome(ctx, principalstore.HomeIdentity{Issuer: "https://id.example", Subject: subject}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, _, _ := testTeamRoster(t, s, app) |
| + rt.principals = ps |
| + s.team = rt |
| + resolver := identityResolver{store: s, team: rt} |
| + id, e := resolver.resolve(identityRequest("GET", subject, homeAssertionMethod)) |
| + if e != nil || id.Actor.PersonID != p.ID || id.Actor.VerifiedAddress != nil || id.HomeSubject != subject { |
| + t.Fatal("wrong Home principal", e) |
| + } |
| + ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, id)) |
| + if e = s.db.Create(&Grant{DocID: doc.ID, Subject: "", Role: RoleWriter, CreatedBy: local}).Error; e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = s.GetFor(ctx, id.Actor, doc.Sqid); !errors.Is(e, gorm.ErrRecordNotFound) { |
| + t.Fatal("nil address redeemed malformed grant", e) |
| + } |
| + if s.CanWrite(ctx, id.Actor, doc.ID) { |
| + t.Fatal("nil address redeemed malformed writer grant") |
| + } |
| + own, e := s.Create(ctx, p.ID, "Home owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = s.Grant(ctx, p.ID, own.Sqid, "guest@example.com", RoleReader); e != nil { |
| + t.Fatal("addressless owner could not grant", e) |
| + } |
| + var people int64 |
| + s.db.Model(&Person{}).Count(&people) |
| + if people != 1 { |
| + t.Fatal("Home identity created legacy person") |
| + } |
| + if _, e = resolver.resolve(identityRequest("GET", "pr_"+strings.Repeat("b", 26), homeAssertionMethod)); e != errIdentityRefused { |
| + t.Fatal("unknown identity allocated") |
| + } |
| + if _, e = resolver.resolve(identityRequest("GET", "unknown@example.com", "unsupported")); e != errIdentityRefused { |
| + t.Fatal("unsupported local method accepted") |
| + } |
| +} |
diff --git a/internal/docs/team_router_test.go b/internal/docs/team_router_test.go| new file mode 100644 |
| index 0000000..1f05d09 |
| --- /dev/null |
| +++ b/internal/docs/team_router_test.go |
| @@ -0,0 +1,332 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +type teamRouterDirectory struct { |
| + mu sync.Mutex |
| + key *family.Keypair |
| + bindings roster.Bindings |
| + generation string |
| + epoch int64 |
| + members []roster.MemberEntry |
| + fail bool |
| +} |
| + |
| +func (d *teamRouterDirectory) RoundTrip(r *http.Request) (*http.Response, error) { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + if d.fail { |
| + return nil, errors.New("directory unavailable") |
| + } |
| + q := r.URL.Query() |
| + if r.URL.Scheme+"://"+r.URL.Host != d.bindings.Directory || r.URL.Path != "/roster" || q.Get("team") != d.bindings.Team || !roster.ValidChallenge(q.Get("fetch")) || q.Get("cursor") != "" || r.Header.Get("Authorization") != "Bearer "+base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) { |
| + return nil, errors.New("wrong roster request scope") |
| + } |
| + index, complete := 0, true |
| + members := append([]roster.MemberEntry{}, d.members...) |
| + p := roster.Page{Version: 1, Type: "page", Directory: d.bindings.Directory, Consumer: d.bindings.Consumer, Team: d.bindings.Team, Audience: d.bindings.Audience, Fetch: q.Get("fetch"), RequestCursorHash: roster.CursorHash(""), Generation: d.generation, Epoch: d.epoch, Index: &index, Members: &members, Complete: &complete} |
| + raw, e := roster.SignPage(d.key, "roster1", p) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil |
| +} |
| +func (d *teamRouterDirectory) change(members []roster.MemberEntry, fail bool) { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + d.members = members |
| + d.epoch++ |
| + d.fail = fail |
| +} |
| + |
| +type teamRouterFixture struct { |
| + *routerHomeFixture |
| + directory *teamRouterDirectory |
| + clock *teamTestClock |
| + cfg Config |
| + alice, bob int64 |
| + own, shared, hidden *Document |
| +} |
| + |
| +func newTeamRouterFixture(t *testing.T) *teamRouterFixture { |
| + t.Helper() |
| + ctx := t.Context() |
| + credential, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + digest := sha256.Sum256(credential.SignPub()) |
| + kid := hex.EncodeToString(digest[:]) |
| + manifest, _ := json.Marshal(map[string]string{kid: base64.StdEncoding.EncodeToString(credential.SignPub())}) |
| + home := HomeConfig{Issuer: bridgeIssuer, ConsumerID: "router-docs", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{83}, 32))} |
| + cfg := Config{DBPath: filepath.Join(t.TempDir(), "team.db"), Env: "development", Origin: bridgeOrigin, Home: &home, AllowedAddresses: "alice@example.com,bob@example.com", SampleDocuments: "0", Mailer: navigationMail{}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| + app, e := New(cfg) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f := &teamRouterFixture{cfg: cfg, clock: &teamTestClock{}} |
| + f.alice, e = app.store.EnsurePerson(ctx, "alice@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.bob, e = app.store.EnsurePerson(ctx, "bob@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.own, e = app.store.Create(ctx, f.alice, "Alice original") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.shared, e = app.store.Create(ctx, f.bob, "Bob shared") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.hidden, e = app.store.Create(ctx, f.bob, "Bob private") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = app.store.Grant(ctx, f.bob, f.shared.Sqid, "alice@example.com", RoleWriter); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, d := range []*Document{f.own, f.shared, f.hidden} { |
| + address := "alice@example.com" |
| + if d.OwnerID == f.bob { |
| + address = "bob@example.com" |
| + } |
| + if _, e = app.store.ApplyUpdate(ctx, d, localActor(d.OwnerID, address), validUpdate(t)); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if e = app.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + operator, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + signer, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + fixed := func(v byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{v}, 32)) } |
| + binding := roster.Bindings{Issuer: bridgeIssuer, Directory: "https://home.example", Consumer: "router-docs", App: "docs", Team: "TeamA", Audience: bridgeOrigin} |
| + directory := &teamRouterDirectory{key: signer, bindings: binding, generation: fixed(1), epoch: 1, members: []roster.MemberEntry{{Subject: teamSubject('a'), Role: "member"}, {Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}} |
| + record := roster.Provisioning{Version: 1, Operation: "team-provision", Sequence: 1, Bindings: binding, Generation: directory.generation, MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(signer.SignPub())}}} |
| + wire, e := roster.SignProvisioning(operator, "operator1", record) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(operator.SignPub())}}) |
| + cfg.Team = &TeamConfig{TeamID: binding.Team, DirectoryOrigin: binding.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(wire), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: fixed(7), clock: f.clock, transport: directory} |
| + app, e = New(cfg) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { |
| + if e := app.Close(); e != nil { |
| + t.Error(e) |
| + } |
| + }) |
| + sessionStore, e := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: bridgeOrigin}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.routerHomeFixture = &routerHomeFixture{app: app, key: credential, keyID: kid, sessions: sessionStore} |
| + f.directory = directory |
| + f.cfg = cfg |
| + return f |
| +} |
| +func teamSubject(c byte) string { return "pr_" + strings.Repeat(string(c), 26) } |
| +func (b *routerBrowser) update(doc *Document, body []byte) int { |
| + r := b.request("POST", "/d/"+doc.Sqid+"/updates", string(body)) |
| + r.Header.Set("Content-Type", "application/octet-stream") |
| + return b.serve(r).Code |
| +} |
| +func TestTeamRouterLocalLinkMembershipRemovalAndIndependentGrant(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + b := f.browser() |
| + b.localSignin(t, "alice@example.com") |
| + if r := b.do("GET", "/d/"+f.own.Sqid, ""); r.Code != 404 { |
| + t.Fatal("email admission became team standing", r.Code) |
| + } |
| + if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 { |
| + t.Fatal("unlinked guest lost exact grant", r.Code) |
| + } |
| + cookie := b.cookies[f.sessions.CookieName()].Value |
| + b.linkProof(t, teamSubject('a'), "alice@example.com") |
| + routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/") |
| + if b.cookies[f.sessions.CookieName()].Value != cookie { |
| + t.Fatal("link rotated local session") |
| + } |
| + for _, d := range []*Document{f.own, f.shared} { |
| + if r := b.do("GET", "/d/"+d.Sqid, ""); r.Code != 200 { |
| + t.Fatal("linked member lost access", r.Code) |
| + } |
| + } |
| + if r := b.do("GET", "/d/"+f.hidden.Sqid, ""); r.Code != 404 { |
| + t.Fatal("membership opened another owner private document", r.Code) |
| + } |
| + if r := b.do("GET", "/d/"+f.shared.Sqid+"/share", ""); r.Code != 404 { |
| + t.Fatal("writer grant became owner administration", r.Code) |
| + } |
| + if r := b.do("GET", "/d/"+f.own.Sqid+"/share", ""); r.Code != 200 { |
| + t.Fatal("member owner cannot administer", r.Code) |
| + } |
| + f.clock.step(roster.RetryAfter) |
| + f.directory.change([]roster.MemberEntry{{Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}, false) |
| + if e := f.app.store.team.cache.Refresh(t.Context()); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, path := range []string{"/d/" + f.own.Sqid, "/d/nonexistent", "/d/" + f.own.Sqid + "/share"} { |
| + if r := b.do("GET", path, ""); r.Code != 404 { |
| + t.Fatal("removed owner retained item access", path, r.Code) |
| + } |
| + } |
| + if r := b.do("POST", "/documents", url.Values{"title": {"refused"}}.Encode()); r.Code != 403 { |
| + t.Fatal("removed member created document", r.Code) |
| + } |
| + if code := b.update(f.own, validUpdate(t)); code != 404 { |
| + t.Fatal("removed owner update not definitely refused", code) |
| + } |
| + if code := b.update(f.shared, validUpdate(t)); code != 204 { |
| + t.Fatal("independent writer grant lost after membership removal", code) |
| + } |
| + f.directory.change(nil, true) |
| + f.clock.step(60 * time.Second) |
| + if e := f.app.store.team.cache.Refresh(t.Context()); e == nil { |
| + t.Fatal("directory failure accepted") |
| + } |
| + for _, path := range []string{"/d/" + f.own.Sqid + "/share", "/d/nonexistent/share"} { |
| + if r := b.do("GET", path, ""); r.Code != 503 { |
| + t.Fatal("unknown owner administration became permanent or leaked existence", r.Code) |
| + } |
| + } |
| + missing := b.do("GET", "/d/nonexistent", "") |
| + private := b.do("GET", "/d/"+f.own.Sqid, "") |
| + if missing.Code != 503 || private.Code != 503 || missing.Body.String() != private.Body.String() { |
| + t.Fatal("unknown standing leaked existence or became permanent refusal") |
| + } |
| + if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 { |
| + t.Fatal("grant unavailable during roster failure", r.Code) |
| + } |
| + if r := b.do("GET", "/", ""); r.Code != 200 || !strings.Contains(r.Body.String(), `rst-notice role="status">docs.team.partial`) || strings.Contains(r.Body.String(), "Alice original") { |
| + t.Fatal("partial list misrepresented unavailable owned documents", r.Code) |
| + } |
| + if r := b.do("GET", "/documents/search?q=shared", ""); r.Code != 200 || r.Header().Get("Docs-Catalog-Partial") != "1" { |
| + t.Fatal("partial search lost status", r.Code) |
| + } |
| + if code := b.update(f.own, validUpdate(t)); code != 503 { |
| + t.Fatal("uncertain update became permanent failure", code) |
| + } |
| + if code := b.update(f.shared, validUpdate(t)); code != 204 { |
| + t.Fatal("writer grant depended on roster availability", code) |
| + } |
| + routerRedirect(t, b.do("POST", "/signout", ""), "/signed-out") |
| + nonce := b.start(t, "/auth/start?return_to=%2F", "") |
| + authTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) |
| + routerRedirect(t, b.exchange(t, nonce, teamSubject('a'), authTime), "/") |
| + session, ok := f.sessions.From(b.request("GET", "/", "")) |
| + if !ok || session.Method != homeAssertionMethod || !session.AuthTime.Equal(authTime) { |
| + t.Fatal("ordinary Home sign-in changed authentication time") |
| + } |
| + if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 { |
| + t.Fatal("linked Home identity lost current verified grant address", r.Code) |
| + } |
| + p, e := f.app.store.team.principals.Resolve(context.Background(), bridgeIssuer, teamSubject('a')) |
| + if e != nil || p.ID != f.alice || p.Kind != principalstore.Local { |
| + t.Fatal("existing local ownership identity changed", e) |
| + } |
| +} |
| + |
| +func TestTeamRouterHomeOnlyFirstEntryAndConflict(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + b := f.browser() |
| + nonce := b.start(t, "/auth/start?return_to=%2F", "") |
| + routerRedirect(t, b.exchange(t, nonce, teamSubject('b'), time.Now().Add(-time.Hour)), "/") |
| + p, e := f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('b')) |
| + if e != nil || p.Kind != principalstore.Home || p.VerifiedAddress != nil { |
| + t.Fatal("first Home entry did not create addressless principal", e) |
| + } |
| + var count int64 |
| + if e = f.app.store.db.Model(&Person{}).Count(&count).Error; e != nil || count != 2 { |
| + t.Fatal("Home entry created legacy email person", count, e) |
| + } |
| + if r := b.do("GET", "/", ""); r.Code != 200 || strings.Contains(r.Body.String(), "Alice original") || strings.Contains(r.Body.String(), "Bob private") { |
| + t.Fatal("new Home principal inherited local ownership", r.Code) |
| + } |
| + r := b.do("POST", "/documents", url.Values{"title": {"Home document"}}.Encode()) |
| + target := routerRedirect(t, r, "") |
| + if !strings.HasPrefix(target, "/d/") { |
| + t.Fatal("member could not create document") |
| + } |
| + var d Document |
| + if e = f.app.store.db.Where("sqid = ?", strings.TrimPrefix(target, "/d/")).First(&d).Error; e != nil || d.OwnerID != p.ID { |
| + t.Fatal("Home-created document has wrong owner", e) |
| + } |
| + nonce = b.start(t, "/auth/start?return_to=%2F", "") |
| + if r = b.exchange(t, nonce, teamSubject('c'), time.Now()); r.Code != 400 { |
| + t.Fatal("existing browser switched principal", r.Code) |
| + } |
| + if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('c')); !errors.Is(e, principalstore.ErrRefused) { |
| + t.Fatal("conflict allocated unknown principal", e) |
| + } |
| + if r = b.do("GET", target, ""); r.Code != 200 { |
| + t.Fatal("conflict replaced current session", r.Code) |
| + } |
| + outsider := f.browser() |
| + nonce = outsider.start(t, "/auth/start?return_to=%2F", "") |
| + if r = outsider.exchange(t, nonce, teamSubject('z'), time.Now()); r.Code != 403 { |
| + t.Fatal("nonmember first entry accepted", r.Code) |
| + } |
| + if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('z')); !errors.Is(e, principalstore.ErrRefused) { |
| + t.Fatal("nonmember allocated principal", e) |
| + } |
| + if r = b.do("GET", "/account/home", ""); r.Code != 303 || r.Header().Get("Location") != "/signin/local" { |
| + t.Fatal("addressless account cannot begin explicit local reauthentication", r.Code, r.Header().Get("Location")) |
| + } |
| +} |
| + |
| +func TestTeamRouterActivatedDatabaseRefusesMissingAndMismatchedConfiguration(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + cfg := f.cfg |
| + cfg.Team = nil |
| + if app, e := New(cfg); e == nil { |
| + app.Close() |
| + t.Fatal("activated database reopened without TeamMode") |
| + } |
| + mismatch := *f.cfg.Team |
| + mismatch.TeamID = "TeamB" |
| + cfg.Team = &mismatch |
| + if app, e := New(cfg); e == nil { |
| + app.Close() |
| + t.Fatal("activated database accepted foreign team configuration") |
| + } |
| + cfg = f.cfg |
| + cfg.Home = nil |
| + if app, e := New(cfg); e == nil { |
| + app.Close() |
| + t.Fatal("activated database accepted missing Home identity configuration") |
| + } |
| +} |
diff --git a/internal/docs/team_session_reader.go b/internal/docs/team_session_reader.go| new file mode 100644 |
| index 0000000..ebd712c |
| --- /dev/null |
| +++ b/internal/docs/team_session_reader.go |
| @@ -0,0 +1,27 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "log/slog" |
| + "net/http" |
| + |
| + canonicalSessions "amadan.net/rastrillo/rastrillo/sessions" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +// Keep validation in the shared reader. Structural conversion fails to compile |
| +// if either framework version changes the fields carried by a session. |
| +func newTeamSessionReader(db *sql.DB, origin, cookieName string, logger *slog.Logger) (func(context.Context, *http.Request) (sessions.Session, bool), error) { |
| + reader, e := canonicalSessions.New(canonicalSessions.Config{DB: db, Origin: origin, Logger: logger}) |
| + if e != nil || reader.CookieName() != cookieName { |
| + return nil, errTeamConfig |
| + } |
| + return func(ctx context.Context, r *http.Request) (sessions.Session, bool) { |
| + if _, present, ok := homeCookie(r, cookieName); !ok || !present { |
| + return sessions.Session{}, false |
| + } |
| + got, ok := reader.FromContext(ctx, r) |
| + return sessions.Session(got), ok |
| + }, nil |
| +} |
diff --git a/internal/docs/team_session_reader_test.go b/internal/docs/team_session_reader_test.go| new file mode 100644 |
| index 0000000..63daad9 |
| --- /dev/null |
| +++ b/internal/docs/team_session_reader_test.go |
| @@ -0,0 +1,104 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +func TestTeamSessionReaderMatchesActualLegacySession(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + reader, e := newTeamSessionReader(f.app.db.Writer(), f.b.origin, f.b.sessionCookie, f.app.logger) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = newTeamSessionReader(f.app.db.Writer(), f.b.origin, "wrong-cookie", f.app.logger); e == nil { |
| + t.Fatal("different cookie configuration accepted") |
| + } |
| + for _, method := range []string{"magiclink", "keymail", homeAssertionMethod} { |
| + t.Run(method, func(t *testing.T) { |
| + r := bridgeRequest("GET", "/", nil) |
| + w := httptest.NewRecorder() |
| + at := time.Now().Add(-time.Hour).Truncate(time.Second) |
| + if e = f.b.sessions.SignIn(w, r, sessions.Session{Subject: "opaque-credential", Method: method, AuthTime: at}); e != nil { |
| + t.Fatal(e) |
| + } |
| + cookie := bridgeCookie(t, w, f.b.sessionCookie) |
| + r.AddCookie(cookie) |
| + old, ok := f.b.sessions.From(r) |
| + if !ok { |
| + t.Fatal("actual legacy session missing") |
| + } |
| + got, ok := reader(t.Context(), r) |
| + if !ok || got != old || !got.AuthTime.Equal(at) || got.Subject != "opaque-credential" || got.Method != method { |
| + t.Fatal("reader conversion changed session authority") |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + if _, ok = reader(ctx, r); ok { |
| + t.Fatal("canceled session lookup accepted") |
| + } |
| + if got, ok = reader(t.Context(), r); !ok || got != old { |
| + t.Fatal("cancellation rotated or refreshed actual session") |
| + } |
| + duplicate := bridgeRequest("GET", "/", []*http.Cookie{cookie, cookie}) |
| + if _, ok = reader(t.Context(), duplicate); ok { |
| + t.Fatal("duplicate actual cookies accepted") |
| + } |
| + if _, e = f.app.db.Writer().Exec("UPDATE sessions SET expires_at=? WHERE token_hash=?", time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), sessions.HashToken(cookie.Value)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, ok = reader(t.Context(), r); ok { |
| + t.Fatal("expired session accepted") |
| + } |
| + if _, ok = f.b.sessions.From(r); ok { |
| + t.Fatal("legacy reader differed on expiry") |
| + } |
| + }) |
| + } |
| + r := bridgeRequest("GET", "/", nil) |
| + if _, ok := reader(t.Context(), r); ok { |
| + t.Fatal("missing session accepted") |
| + } |
| + r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "malformed"}) |
| + if _, ok := reader(t.Context(), r); ok { |
| + t.Fatal("malformed session accepted") |
| + } |
| +} |
| + |
| +func TestTeamSessionReaderRevocationAndDatabaseErrorParity(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + reader, e := newTeamSessionReader(f.app.db.Writer(), f.b.origin, f.b.sessionCookie, f.app.logger) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r := bridgeRequest("GET", "/", nil) |
| + w := httptest.NewRecorder() |
| + if e := f.b.sessions.SignIn(w, r, sessions.Session{Subject: "alice@example.com", Method: "magiclink", AuthTime: time.Now()}); e != nil { |
| + t.Fatal(e) |
| + } |
| + r.AddCookie(bridgeCookie(t, w, f.b.sessionCookie)) |
| + if _, ok := reader(t.Context(), r); !ok { |
| + t.Fatal("live session refused") |
| + } |
| + f.b.sessions.SignOut(httptest.NewRecorder(), r) |
| + if _, ok := reader(t.Context(), r); ok { |
| + t.Fatal("revoked session accepted") |
| + } |
| + if _, ok := f.b.sessions.From(r); ok { |
| + t.Fatal("legacy revocation differs") |
| + } |
| + if e := f.app.db.Writer().Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, ok := reader(t.Context(), r); ok { |
| + t.Fatal("database failure accepted") |
| + } |
| + if _, ok := f.b.sessions.From(r); ok { |
| + t.Fatal("legacy database failure differs") |
| + } |
| +} |
diff --git a/internal/docs/team_stream.go b/internal/docs/team_stream.go| new file mode 100644 |
| index 0000000..29d07cf |
| --- /dev/null |
| +++ b/internal/docs/team_stream.go |
| @@ -0,0 +1,64 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "time" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +type teamStreamGuard struct { |
| + store *Store |
| + access *requestAccess |
| + request *http.Request |
| + docID int64 |
| + deadline time.Time |
| +} |
| + |
| +func (g *teamStreamGuard) remaining() time.Duration { |
| + if g.access.uncertain { |
| + return 0 |
| + } |
| + if g.access.decision.State == roster.Member { |
| + if g.deadline.IsZero() { |
| + return 0 |
| + } |
| + return min(g.access.decision.Lease.Remaining(), time.Until(g.deadline)) |
| + } |
| + return defaultStreamWriteDeadline |
| +} |
| + |
| +// A stream never renews its lease. Reconnection obtains fresh admission, so a |
| +// blocked cache publisher cannot hold an expired stream open during refresh. |
| +func (g *teamStreamGuard) check() (time.Duration, bool) { |
| + t := g.store.team |
| + remaining := g.remaining() |
| + if t == nil || t.bridge == nil || t.readSession == nil || remaining <= 0 { |
| + return 0, false |
| + } |
| + limit := min(remaining, defaultStreamWriteDeadline) |
| + ctx, cancel := context.WithTimeout(g.request.Context(), limit) |
| + defer cancel() |
| + if _, present, ok := homeCookie(g.request, t.bridge.sessionCookie); !ok || !present { |
| + return 0, false |
| + } |
| + session, ok := t.readSession(ctx, g.request) |
| + if !ok || ctx.Err() != nil { |
| + return 0, false |
| + } |
| + request := sessions.WithSession(g.request.WithContext(ctx), session) |
| + id, e := t.bridge.resolver.resolve(request) |
| + if e != nil || !sameActor(id.Actor, g.access.actor) || id.HomeSubject != g.access.subject { |
| + return 0, false |
| + } |
| + if e = g.store.checkPermission(ctx, g.store.db.WithContext(ctx), id.Actor, g.docID, permissionRead); e != nil { |
| + return 0, false |
| + } |
| + remaining = g.remaining() |
| + if ctx.Err() != nil || remaining <= 0 { |
| + return 0, false |
| + } |
| + return min(remaining, defaultStreamWriteDeadline), true |
| +} |
diff --git a/internal/docs/team_stream_test.go b/internal/docs/team_stream_test.go| new file mode 100644 |
| index 0000000..13bfea1 |
| --- /dev/null |
| +++ b/internal/docs/team_stream_test.go |
| @@ -0,0 +1,153 @@ |
| +package docs |
| + |
| +import ( |
| + "amadan.net/oficina/home/roster" |
| + "bufio" |
| + "context" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func teamOpenActualStream(t *testing.T, f *teamRouterFixture, b *routerBrowser, srv *httptest.Server) *http.Response { |
| + t.Helper() |
| + r, e := http.NewRequestWithContext(t.Context(), "GET", srv.URL+"/d/"+f.own.Sqid+"/stream", nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + r.Host = strings.TrimPrefix(bridgeOrigin, "https://") |
| + for _, c := range b.cookies { |
| + r.AddCookie(c) |
| + } |
| + res, e := srv.Client().Do(r) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { res.Body.Close() }) |
| + if res.StatusCode != 200 { |
| + raw, _ := io.ReadAll(res.Body) |
| + t.Fatal("stream refused", res.StatusCode, res.Request.URL.String(), string(raw)) |
| + } |
| + reader := bufio.NewReader(res.Body) |
| + name, _ := nextEvent(t, reader, time.Second) |
| + if name != "update" { |
| + t.Fatal("initial sequence event missing") |
| + } |
| + // Keep the same buffered reader: it may already contain the next frame. |
| + res.Body = struct { |
| + io.Reader |
| + io.Closer |
| + }{reader, res.Body} |
| + return res |
| +} |
| +func TestTeamStreamActualSessionWriterWaitCannotExtendOriginalLease(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + b := f.browser() |
| + b.localSignin(t, "alice@example.com") |
| + b.linkProof(t, teamSubject('a'), "alice@example.com") |
| + routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/") |
| + if r := b.do("GET", "/d/"+f.own.Sqid, ""); r.Code != 200 { |
| + t.Fatal("initial membership unavailable", r.Code) |
| + } |
| + streamDurations(t, f.app, 40*time.Millisecond, 5*time.Second) |
| + f.directory.change(nil, true) |
| + f.clock.step(59 * time.Second) |
| + if e := f.app.store.team.cache.Refresh(t.Context()); e == nil { |
| + t.Fatal("failed directory refresh accepted") |
| + } |
| + srv := httptest.NewServer(f.app.Handler()) |
| + defer srv.Close() |
| + res := teamOpenActualStream(t, f, b, srv) |
| + defer res.Body.Close() |
| + writer := f.app.db.Writer() |
| + tx, e := writer.BeginTx(t.Context(), nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + baseline := writer.Stats().WaitCount |
| + done := make(chan struct{}) |
| + go func() { io.Copy(io.Discard, res.Body); close(done) }() |
| + deadline := time.Now().Add(300 * time.Millisecond) |
| + for writer.Stats().WaitCount == baseline && time.Now().Before(deadline) { |
| + time.Sleep(time.Millisecond) |
| + } |
| + if writer.Stats().WaitCount == baseline { |
| + t.Fatal("actual session reader never queued behind writer") |
| + } |
| + select { |
| + case <-done: |
| + case <-time.After(1300 * time.Millisecond): |
| + t.Fatal("writer wait held stream beyond its original lease") |
| + } |
| + // The stream ended while the writer remains locked; cancellation did not need |
| + // the transaction to finish and did not mint a new session. |
| + if e = tx.Rollback(); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, ok := f.sessions.From(b.request("GET", "/", "")); !ok { |
| + t.Fatal("stream cancellation revoked legacy session") |
| + } |
| +} |
| +func TestTeamStreamHealthyPublicationClosesOriginalLeaseAndReconnects(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + b := f.browser() |
| + b.localSignin(t, "alice@example.com") |
| + b.linkProof(t, teamSubject('a'), "alice@example.com") |
| + routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/") |
| + streamDurations(t, f.app, 30*time.Millisecond, 5*time.Second) |
| + srv := httptest.NewServer(f.app.Handler()) |
| + defer srv.Close() |
| + res := teamOpenActualStream(t, f, b, srv) |
| + defer res.Body.Close() |
| + f.clock.step(roster.RetryAfter) |
| + if e := f.app.store.team.cache.Refresh(context.Background()); e != nil { |
| + t.Fatal(e) |
| + } |
| + done := make(chan struct{}) |
| + go func() { io.Copy(io.Discard, res.Body); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(time.Second): |
| + t.Fatal("old stream renewed instead of closing after publication") |
| + } |
| + fresh := teamOpenActualStream(t, f, b, srv) |
| + fresh.Body.Close() |
| +} |
| + |
| +func TestTeamStreamGuestGrantAndActualSessionRevocation(t *testing.T) { |
| + for _, reason := range []string{"grant", "session"} { |
| + t.Run(reason, func(t *testing.T) { |
| + f := newTeamRouterFixture(t) |
| + guest := f.browser() |
| + guest.localSignin(t, "alice@example.com") |
| + owner := f.browser() |
| + owner.localSignin(t, "bob@example.com") |
| + owner.linkProof(t, teamSubject('b'), "bob@example.com") |
| + routerRedirect(t, owner.do("POST", "/account/home/confirm", ""), "/") |
| + streamDurations(t, f.app, 30*time.Millisecond, 5*time.Second) |
| + srv := httptest.NewServer(f.app.Handler()) |
| + defer srv.Close() |
| + shared := *f |
| + shared.own = f.shared |
| + res := teamOpenActualStream(t, &shared, guest, srv) |
| + defer res.Body.Close() |
| + if reason == "grant" { |
| + routerRedirect(t, owner.do("POST", "/d/"+f.shared.Sqid+"/share/revoke", url.Values{"address": {"alice@example.com"}}.Encode()), "/d/"+f.shared.Sqid+"/share") |
| + } else { |
| + routerRedirect(t, guest.do("POST", "/signout", ""), "/signed-out") |
| + } |
| + done := make(chan struct{}) |
| + go func() { io.Copy(io.Discard, res.Body); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(time.Second): |
| + t.Fatal("live stream survived actual " + reason + " revocation") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/docs/teamstate/migrations/0001_team.sql b/internal/docs/teamstate/migrations/0001_team.sql| new file mode 100644 |
| index 0000000..637b599 |
| --- /dev/null |
| +++ b/internal/docs/teamstate/migrations/0001_team.sql |
| @@ -0,0 +1,10 @@ |
| +CREATE TABLE docs_team_required ( |
| + id INTEGER PRIMARY KEY CHECK(id=1), |
| + bindings TEXT NOT NULL CHECK(typeof(bindings)='text' AND length(bindings)=43) |
| +); |
| +CREATE TRIGGER docs_team_required_keep_update BEFORE UPDATE ON docs_team_required BEGIN SELECT RAISE(ABORT,'docs team: refused'); END; |
| +CREATE TRIGGER docs_team_required_keep_delete BEFORE DELETE ON docs_team_required BEGIN SELECT RAISE(ABORT,'docs team: refused'); END; |
| +CREATE TABLE docs_team_roster_evidence ( |
| + id INTEGER PRIMARY KEY CHECK(id=1), |
| + payload BLOB NOT NULL CHECK(typeof(payload)='blob' AND length(payload)<=1048704) |
| +); |
diff --git a/internal/docs/teamstate/store.go b/internal/docs/teamstate/store.go| new file mode 100644 |
| index 0000000..00d016a |
| --- /dev/null |
| +++ b/internal/docs/teamstate/store.go |
| @@ -0,0 +1,98 @@ |
| +// Package teamstate keeps the mandatory-mode marker and opaque roster evidence. |
| +package teamstate |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "embed" |
| + "encoding/base64" |
| + "errors" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| +var Schema = migrate.MustFromFS(migrations, "docs_team") |
| +var ErrRefused = errors.New("docs team: refused") |
| +var ErrUnavailable = errors.New("docs team: unavailable") |
| + |
| +func validBinding(s string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| + |
| +// Required distinguishes a never-activated database from an unreadable marker. |
| +func Required(ctx context.Context, db *sql.DB) (string, bool, error) { |
| + var count int |
| + if e := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='docs_team_required'").Scan(&count); e != nil { |
| + return "", false, ErrUnavailable |
| + } |
| + if count == 0 { |
| + return "", false, nil |
| + } |
| + var value string |
| + e := db.QueryRowContext(ctx, "SELECT bindings FROM docs_team_required WHERE id=1").Scan(&value) |
| + if errors.Is(e, sql.ErrNoRows) { |
| + return "", false, nil |
| + } |
| + if e != nil { |
| + return "", false, ErrUnavailable |
| + } |
| + if !validBinding(value) { |
| + return "", false, ErrRefused |
| + } |
| + return value, true, nil |
| +} |
| + |
| +// Activate commits before the caller may serve requests or allocate identities. |
| +// It records an already-verified deployment binding, never an owner or roster. |
| +func Activate(ctx context.Context, db *sql.DB, binding string) error { |
| + if !validBinding(binding) { |
| + return ErrRefused |
| + } |
| + tx, e := db.BeginTx(ctx, nil) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + if _, e = tx.ExecContext(ctx, "INSERT INTO docs_team_required(id,bindings) VALUES(1,?) ON CONFLICT(id) DO NOTHING", binding); e != nil { |
| + return ErrUnavailable |
| + } |
| + var got string |
| + if e = tx.QueryRowContext(ctx, "SELECT bindings FROM docs_team_required WHERE id=1").Scan(&got); e != nil { |
| + return ErrUnavailable |
| + } |
| + if got != binding { |
| + return ErrRefused |
| + } |
| + if tx.Commit() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +type Evidence struct{ DB *sql.DB } |
| + |
| +func (s Evidence) Load(ctx context.Context) ([]byte, error) { |
| + var b []byte |
| + e := s.DB.QueryRowContext(ctx, "SELECT payload FROM docs_team_roster_evidence WHERE id=1").Scan(&b) |
| + if errors.Is(e, sql.ErrNoRows) { |
| + return nil, roster.ErrNoEvidence |
| + } |
| + if e != nil || len(b) > roster.EvidenceLimit+128 { |
| + return nil, ErrUnavailable |
| + } |
| + return b, nil |
| +} |
| +func (s Evidence) Save(ctx context.Context, b []byte) error { |
| + if len(b) == 0 || len(b) > roster.EvidenceLimit+128 { |
| + return ErrRefused |
| + } |
| + _, e := s.DB.ExecContext(ctx, "INSERT INTO docs_team_roster_evidence(id,payload) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload", b) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/teamstate/store_test.go b/internal/docs/teamstate/store_test.go| new file mode 100644 |
| index 0000000..097ec5f |
| --- /dev/null |
| +++ b/internal/docs/teamstate/store_test.go |
| @@ -0,0 +1,89 @@ |
| +package teamstate |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +func fixture(t *testing.T) *db.DB { |
| + t.Helper() |
| + d, e := db.Open(filepath.Join(t.TempDir(), "team.db"), nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + return d |
| +} |
| +func TestMarkerActivationAndImmutability(t *testing.T) { |
| + d := fixture(t) |
| + ctx := context.Background() |
| + if _, yes, e := Required(ctx, d.Writer()); e != nil || yes { |
| + t.Fatal("fresh DB requires team") |
| + } |
| + if _, e := migrate.Apply(ctx, d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, yes, e := Required(ctx, d.Writer()); e != nil || yes { |
| + t.Fatal("schema alone activated mode") |
| + } |
| + b := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) |
| + if e := Activate(ctx, d.Writer(), b); e != nil { |
| + t.Fatal(e) |
| + } |
| + if got, yes, e := Required(ctx, d.Writer()); e != nil || !yes || got != b { |
| + t.Fatal("marker missing") |
| + } |
| + if e := Activate(ctx, d.Writer(), b); e != nil { |
| + t.Fatal("idempotent activation", e) |
| + } |
| + other := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)) |
| + if Activate(ctx, d.Writer(), other) != ErrRefused { |
| + t.Fatal("binding changed") |
| + } |
| + for _, q := range []string{"DELETE FROM docs_team_required", "UPDATE docs_team_required SET bindings=bindings"} { |
| + if _, e := d.Writer().Exec(q); e == nil { |
| + t.Fatal("marker mutable") |
| + } |
| + } |
| +} |
| +func TestMalformedMarkerAndEvidenceFailure(t *testing.T) { |
| + d := fixture(t) |
| + ctx := context.Background() |
| + if _, e := migrate.Apply(ctx, d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := d.Writer().Exec("INSERT INTO docs_team_required VALUES(1,?)", strings.Repeat("?", 43)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, _, e := Required(ctx, d.Writer()); e != ErrRefused { |
| + t.Fatal("malformed marker accepted") |
| + } |
| + evidence := Evidence{DB: d.Writer()} |
| + if _, e := evidence.Load(ctx); e != roster.ErrNoEvidence { |
| + t.Fatal(e) |
| + } |
| + first := []byte("opaque sealed test envelope") |
| + if e := evidence.Save(ctx, first); e != nil { |
| + t.Fatal(e) |
| + } |
| + cancelled, cancel := context.WithCancel(ctx) |
| + cancel() |
| + if evidence.Save(cancelled, []byte("replacement")) == nil { |
| + t.Fatal("cancelled replacement succeeded") |
| + } |
| + if evidence.Save(ctx, make([]byte, roster.EvidenceLimit+129)) != ErrRefused { |
| + t.Fatal("oversize accepted") |
| + } |
| + got, e := evidence.Load(ctx) |
| + if e != nil || !bytes.Equal(got, first) { |
| + t.Fatal("failed write replaced evidence") |
| + } |
| +} |
diff --git a/internal/docs/testing.go b/internal/docs/testing.go| index 29562f2..7ff32a8 100644 |
| --- a/internal/docs/testing.go |
| +++ b/internal/docs/testing.go |
| @@ -279,7 +279,7 @@ func (a *App) TestActorForPerson(t *testing.T, person int64) Actor { |
| if err := a.Store().db.Where("id = ?", person).First(&p).Error; err != nil { |
| t.Fatalf("TestActorForPerson: resolving person %d: %v", person, err) |
| } |
| - return Actor{PersonID: p.ID, Subject: p.Email} |
| + return localActor(p.ID, p.Email) |
| } |
| // TestEditorScriptPath is the served path of the editor bundle, exported so |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index 406bb63..cfe9fda 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -317,6 +317,7 @@ |
| </form>{{end}} |
| </div> |
| </header> |
| +{{if .PartialCatalog}}<p rst-notice role="status">{{.PartialCatalog}}</p>{{end}} |
| {{/* Searching renders a ranked, UNGROUPED result list. Recency |
| grouping is what an unfiltered list is for; grouping ranked |
| results under "Today" and "Earlier" would order the same rows |
diff --git a/internal/docs/views/partials/jumpto.html b/internal/docs/views/partials/jumpto.html| index a1f9f6e..a6c6c5e 100644 |
| --- a/internal/docs/views/partials/jumpto.html |
| +++ b/internal/docs/views/partials/jumpto.html |
| @@ -111,7 +111,7 @@ |
| {{/* role="status" so a result count or a failure is announced |
| without stealing focus from the field being typed into. */}} |
| <p docs-jumpto-status role="status" aria-live="polite" hidden |
| - data-empty="{{.JumpTo.Empty}}" data-failed="{{.JumpTo.Failed}}"></p> |
| + data-partial="{{.JumpTo.Partial}}" data-empty="{{.JumpTo.Empty}}" data-failed="{{.JumpTo.Failed}}"></p> |
| <p docs-jumpto-hint>{{.JumpTo.Hint}}</p> |
| </div> |
| </div> |
diff --git a/scripts/check-principal-compat.py b/scripts/check-principal-compat.py| new file mode 100755 |
| index 0000000..081f730 |
| --- /dev/null |
| +++ b/scripts/check-principal-compat.py |
| @@ -0,0 +1,65 @@ |
| +#!/usr/bin/env python3 |
| +"""Build frozen old/new store probes and exercise disposable rollback databases.""" |
| +import argparse |
| +import io |
| +import json |
| +import pathlib |
| +import subprocess |
| +import sqlite3 |
| +import tarfile |
| + |
| +parser = argparse.ArgumentParser() |
| +parser.add_argument("--artifacts", required=True) |
| +args = parser.parse_args() |
| +repo = pathlib.Path(__file__).resolve().parents[1] |
| +out = pathlib.Path(args.artifacts).resolve() |
| +out.mkdir(parents=True, exist_ok=False) |
| +current = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() |
| +versions = {"old876": "87662f5", "old8a": "8a126727", "old3ed": "3ed7665", "new": current} |
| +fixtures = repo / "internal/docs/principalstore/testdata" |
| +records = [] |
| +for name, rev in versions.items(): |
| + sha = subprocess.check_output(["git", "rev-parse", rev], cwd=repo, text=True).strip() |
| + src = out / name |
| + src.mkdir() |
| + archive = subprocess.check_output(["git", "archive", sha], cwd=repo) |
| + with tarfile.open(fileobj=io.BytesIO(archive)) as tree: |
| + tree.extractall(src, filter="data") |
| + cmd = src / "cmd/principal-compat" |
| + cmd.mkdir() |
| + (cmd / "main.go").write_bytes((fixtures / "compat_main.go.txt").read_bytes()) |
| + step = "new" if name == "new" else "old" |
| + (cmd / "step.go").write_bytes((fixtures / f"compat_{step}.go.txt").read_bytes()) |
| + with (out / f"{name}-build.log").open("wb") as log: |
| + subprocess.run(["go", "build", "-o", str(out / f"{name}-proof"), "./cmd/principal-compat"], cwd=src, stdout=log, stderr=log, check=True) |
| + records.append({"name": name, "sha": sha}) |
| +(out / "builds.json").write_text(json.dumps(records, indent=2)) |
| +results = {} |
| +for old in ["old876", "old8a", "old3ed"]: |
| + db = out / f"{old}-cycle.db" |
| + phases = [] |
| + for index, binary in enumerate([old, "new", old, "new"]): |
| + phase = f"{old}-{index}" |
| + with (out / f"{phase}.stderr").open("wb") as log: |
| + raw = subprocess.check_output([str(out / f"{binary}-proof"), "--db", str(db), "--address", f"synthetic-{index}@example.invalid", "--phase", phase], cwd=out / binary, stderr=log) |
| + (out / f"{phase}.json").write_bytes(raw) |
| + result = json.loads(raw) |
| + if result["local_id"] != index + 1: |
| + raise RuntimeError("old/new allocation or LastInsertId changed") |
| + if index and any(result[k] != phases[0][k] for k in ["owner", "grant_author", "sqid", "content_hash", "encodings", "text"]): |
| + raise RuntimeError("owner/grant/content changed across rollback") |
| + if binary == "new" and (result["home_id"] != 2**52 or result["home_owned"] != 1): |
| + raise RuntimeError("Home identity/ownership changed") |
| + if index == 2 and result["home_owned"] != 1: |
| + raise RuntimeError("old binary lost Home ownership") |
| + if index and result["principal_count"] != index + 2: |
| + raise RuntimeError("legacy insert did not mirror the canonical ledger") |
| + phases.append(result) |
| + with sqlite3.connect(db) as check: |
| + if check.execute("PRAGMA foreign_key_check").fetchall(): |
| + raise RuntimeError("foreign key damage across rollback") |
| + if check.execute("SELECT COUNT(*) FROM people WHERE email='' OR id>=4503599627370496").fetchone()[0]: |
| + raise RuntimeError("Home-only identity leaked into legacy people") |
| + results[old] = phases |
| +(out / "verified.json").write_text(json.dumps({"candidate": current, "versions": records, "cycles": results}, indent=2)) |
| +print(out / "verified.json") |