Merge branch 'feat/directory-runtime' into main
21 files changed,
+1548
−4
.gitignore+2 −0implementation/directory-runtime.md+41 −0implementation/directory-store.md+13 −0implementation/docs-chooser-continuation-design.md+49 −0implementation/docs-team-chooser.md+15 −0internal/home/app.go+3 −2internal/home/directory.go+9 −1internal/home/directory_runtime.go+84 −0internal/home/directory_runtime_test.go+199 −0internal/home/directorydata/migrations/0001_directory.sql+23 −0internal/home/directorydata/store.go+254 −0internal/home/directorydata/store_test.go+255 −0internal/home/docs_chooser.go+159 −0internal/home/docs_chooser_test.go+261 −0internal/home/docs_continue_test.go+149 −0internal/home/runtime.go+2 −1internal/home/templates/docs_chooser.html+11 −0internal/home/templates/docs_continue.html+6 −0internal/home/templates/docs_empty.html+6 −0internal/home/templates/docs_unavailable.html+6 −0internal/home/templates/layout.html+1 −0
CI passed — run details
diff --git a/.gitignore b/.gitignore| index 1181530..82408b5 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -16,3 +16,5 @@ |
| .claude/launch.json |
| .claude/settings.local.json |
| .superpowers/ |
| + |
| +writing/ |
diff --git a/implementation/directory-runtime.md b/implementation/directory-runtime.md| new file mode 100644 |
| index 0000000..f7d9211 |
| --- /dev/null |
| +++ b/implementation/directory-runtime.md |
| @@ -0,0 +1,41 @@ |
| +# Mount the directory-owned Docs entry |
| + |
| +Status: base9ccdd2b independently APPROVED by configured claude-fable-5 (Home29); the expired-session continuation clarification below requires a follow-up verdict. Base is reviewed, unmounted chooser07043915daebae10512a710915263093ed793b5b and directory store4c0d84811a018c53a47b604a0de5885baec0771e. Home discussion24 approved the parent directory designfa64e28. Docs Home linking is now deployed1e862f8 and merged5fe0447. This plan does not make that existing-account pilot into authoritative team admission. |
| + |
| +## Existing source and boundary |
| + |
| +`runtime.go` applies only sessions and shared handoff migrations in the directory role. Credential and legacy roles use BootSchema. `newDirectory` constructs its own sessions, renderer and shared consumer, then `directoryRouter` mounts exact-host validation, session middleware and a CSRF-protected group. The existing directory page is a signed-in placeholder. No directory operator/provisioning write API exists. `directorydata` already supplies sealed team names, live membership choices and required caller destination validation; `docsChooser` is already reviewed but unmounted. |
| + |
| +Mounting this read path adds no membership, team, owner, consumer, queue request or app instance. An empty existing directory remains empty. Do not import credential people migrations, pepper, mail or private assertion keys into the directory process. Do not seed the running preview DB or infer ownership from its first visitor. Platform117 was read again on2026-09-08 and still records no fenced-maintenance implementation. Operator/bootstrap transport and real two-team provisioning remain separate prerequisites. |
| + |
| +## Compiled app registry and destination policy |
| + |
| +Introduce a small compiled registry type with slug, display name, integrated and provisionable fields, plus a foreign origin allowed only for unintegrated entries, matching Home spec section11. This slice has one supported entry: Docs, slug docs, integrated true, provisionable false. Integrated means this app has the reviewed assertion consumer; it is not a readiness badge. No new app grid or New action is rendered in this slice, and the other suite apps are not declared ready or replaced by external links. Future registry entries require their actual consumer/provisioning evidence. |
| + |
| +The directory store receives the integrated slug set copied from this registry. Its route validator accepts only an integrated registered app and an exact origin equal to https://<stored-label>.<registered-slug>.oficina.works. The existing store validates the stored label, sqid, consumer ID and canonical HTTPS origin before calling this policy. No request supplies a hostname, foreign URL, URL suffix, app registry entry or policy override. Origin matching uses constructed string equality, not suffix matching. Test lookalike domains, prefix tricks, case, ports, paths, credentials and external origins. |
| + |
| +A live team_instances row is an authoritative directory record created only by the separately reviewed operator transaction after exact credential-consumer registration is verified. This read policy does not pretend to independently query the credential process or turn a syntactically valid consumer ID into proof of registration. It validates the canonical route while the future audited lifecycle writer owns the registration check. There is currently no exported network writer that can mark an instance live. Consumer ID is not arbitrarily re-derived here: the row stores the operator-registered ID, whose existing bounded structural validation remains required. |
| + |
| +Do not add per-team environment pins, wildcard destination policies or an immutable duplicate of all team records. This runtime slice does not accept the current overnight-docs platform hostname as a team origin. A separately reviewed pilot manifest or real canonical aliases with an audited registration record is needed before live synthetic team rows can point there. Tests can use canonical alpha.docs.oficina.works and beta.docs.oficina.works with loopback TLS routing; those are fixtures, not live provisioned teams. |
| + |
| +## Construction and route mounting |
| + |
| +Merge directorydata.Schema after existing directory sessions/handoff sets in directorySchema only. Keep both credential and legacy BootSchema unchanged. Construct a directorydata.Store inside newDirectory using the existing directory DB writer and sealer, then construct the Docs chooser. Keep these as private App fields or in a private directory runtime bundle; any constructor error refuses boot with a fixed error, without logging stored names or routes. Role validation remains mandatory even for direct constructor tests. |
| + |
| +Mount GET /apps/docs and POST /apps/docs/team under the directory's existing exact-host and session middleware, inside the normal CSRF group. Do not add another login form or require users to authenticate separately on the directory. Anonymous GET uses the chooser's existing fixed shared-consumer return to /apps/docs. Selection keeps its own reviewed CSRF check as well. A POST with neither valid Origin/Referer nor accepted fetch metadata must refuse; test the real framework behavior rather than assuming all missing-Origin requests are forbidden. A real same-origin browser POST supplies accepted fetch metadata. Foreign origins, including the credential sibling, must refuse. |
| + |
| +Before chooser handlers consult the session, reject duplicate actual session cookies for these two chooser routes. Do not change the existing assertion callback or global session flow in this slice. Constructor tests exercise the actual App.Handler and role routing so credential and legacy hosts cannot reach the chooser. Every chooser response, including errors and unknown-host refusal at the app boundary, remains no-store; use the narrow route boundary as needed without weakening shared host checks. |
| + |
| +Zero choices renders the reviewed empty page; one choice uses the existing303 GET redirect to the registered app's /auth/start with local return /. Multiple choices render the reviewed form. The successful POST response depends on separately reviewed continuation amendment95dcd682ac106ab725d9a85f141285709ab3659c: return200, immediate meta-refresh plus a plain fallback link to the revalidated stored destination. Preserve CSRF and the post-selection membership re-query. This avoids an unbounded outgoing CSP list; no wildcard or global form-action change. Do not implement or deploy the successful POST integration before that amendment is approved and its browser/fallback tests pass. |
| + |
| +A source-backed browser check exposed a second redirect-chain case: an expired directory session on an otherwise same-origin chooser POST currently returns303 to /auth/start and then to the credential origin, where form-action self blocks it. Handle this inside the chooser subject helper after Select has applied its existing method/path checks and CSRF protection. When that helper finds no current session on POST, render the same200 continuation template to the fixed validated directory origin plus /auth/start?return_to=%2Fapps%2Fdocs. An anonymous GET still uses the existing303. Refactor only the common final rendering/header step; the successful-choice caller validates its stored Docs origin, while the reauthentication caller constructs its destination entirely from validated runtime origin and fixed constants. No submitted team, arbitrary URL, query parameter or resource path enters that destination. Both set no-store and no-referrer and keep a real plain fallback link. This starts authentication only; it does not retain the stale submitted selection or create a membership/session/proof. After sign-in the chooser re-queries current membership and the person selects again if several teams remain. Test real expired-session POST in Chromium/WebKit with scripts on/off, fallback navigation, fixed Home return and unchanged CSRF/unknown-host refusal; do not treat the old blocked behavior as accepted integration. |
| + |
| +Generic app entry never transfers a document path through Home or guesses which team's resource it is. Direct resource returns stay in the original Docs instance's sealed handoff. No global selected-team cookie is introduced; two tabs select independently. The public bare docs.oficina.works alias remains its original instance until the separately reviewed compatibility cutover. This slice adds only the directory /apps/docs entry path, not a new bare-host gateway or deployment route. |
| + |
| +## Evidence and release sequence |
| + |
| +Use actual LoadRuntime/NewRuntime/App.Handler tests, not only the unmounted chooser fixture. Assert directorySchema gains only the approved namespace; credential/legacy schema and secret loading remain unchanged. Reopen a disposable migrated directory DB with exact old live Home02f9ac0 code and show existing directory sessions/handoff/signout survive, then reopen with new code without data loss. No platform restore claim follows from that test. |
| + |
| +Seed synthetic teams only through explicit local test fixtures; test zero/one/two choices, canonical route rejection, disabled/pending instances, membership removal after rendering, duplicate cookies, wrong hosts, wrong methods, extra query/form fields, real missing-Origin/fetch-metadata cases, fixed anonymous return and two independent tabs. Extend existing privacy gates over newly mounted schemas, sealed names and errors. Repeat actual Chromium/WebKit narrow/wide and scripts on/off flows with the shared issuer and canonical synthetic Docs origins, including successful POST continuation and plain fallback; no test-only CSP relaxation. |
| + |
| +After full Home gate, actual exact-head CI and independent whole-branch Fable approval, deploy the binary to the existing paired Home previews with original roles, keys, consumers and DB paths unchanged. Verify both API body and edge header. The existing directory will expose the empty chooser until audited team provisioning exists; announce exactly that scope. Live unsigned entry and authenticated empty-state checks must be distinguished; do not fabricate a human session to claim the latter. Obtain canary evidence review before normal Amadan merge. This incremental mount does not complete roster admission, team creation, owner step-up, guest/ex-member app tiles, production gateway cutover or restore acceptance. |
diff --git a/implementation/directory-store.md b/implementation/directory-store.md| new file mode 100644 |
| index 0000000..672cd78 |
| --- /dev/null |
| +++ b/implementation/directory-store.md |
| @@ -0,0 +1,13 @@ |
| +# Directory store implementation slice |
| + |
| +Source design: approved Home fa64e286990762aecc6913bb9076a3cb75b088c3, discussion24. This branch implements only the directory-owned additive schema, sealed-name helper and membership-derived lookup. Discussion25 owns code review. No runtime schema merge, route, UI, owner/provisioning write entrypoint, roster HTTP or deployment is included. |
| + |
| +`internal/home/directorydata.Schema` is a separate migration namespace. Future integration merges it into `runtime.go` directorySchema, never credential BootSchema. It creates teams, team_instances and memberships only. Teams have AUTOINCREMENT IDs, unique sqid/label, sealed name, creation time and per-team epoch. No people/address/credential schema is applied. ID allocation and name sealing belong in the future audited caller transaction; the helper never inserts a team or owner. The test fixture reserves a row with a non-plaintext placeholder, seals with the allocated ID and updates it before commit. |
| + |
| +`New(db, sealer, Config{Apps, ValidateRoute})` copies the compiled app slug list. A required caller route-validation policy enforces provisioning origin/consumer rules; production may derive the canonical hostname while a reviewed isolated pilot may use explicit origins. The store enforces canonical HTTPS origins and bounded identifiers first. This avoids duplicating every authoritative directory row in immutable process configuration, which the approved design does not require. The caller owns the policy closure and must keep it safe for concurrent reads. |
| + |
| +`TeamForMember(ctx, personRef, sqid)` returns only an existing membership, with the same fixed error for foreign and unknown teams. `Choices(ctx, personRef, app)` returns current membership-derived live instances ordered by immutable team ID; pending/disabled instances are omitted. Invalid stored configuration refuses the entire list. The read cap is 10,000 matching rows, with overflow refused rather than a partial list. These are navigation facts, not item grants or a roster cache. Every call queries current records; membership removal is reflected without a process restart. |
| + |
| +`SealTeamName(teamID, name)` authenticates up to 256 UTF-8 bytes under purpose directory.team-name/<immutable integer ID>. No plaintext name is bound to SQL by store code. Caller writes only the returned bytes. Errors are fixed and do not wrap lower-level messages or user values. DNS label reservation and sqid allocation remain future provisioning responsibilities; this read store checks their structural form. |
| + |
| +Validation: full make test passed, plus go test -race ./internal/home/directorydata. Tests cover zero/one/two-team isolation, owner/member vocabulary, removal, disabled/pending instances, no automatic membership, raw DB/WAL/SHM plaintext scan, swapped/corrupt name ciphertext refusal, ID non-reuse, migration idempotency and schema constraints, route-policy rejection, unknown lifecycle, whole-list refusal and error privacy. Two Go overlay mutations failed the intended tests: removing the subject predicate exposes the other team's choice, and removing ID-specific sealing accepts a swapped name. Artifacts /tmp/oficina-directory-store-test.log and /tmp/oficina-directory-store-mutations. Independent code review and actual branch CI remain required; no canary is claimed for this unmounted package. |
diff --git a/implementation/docs-chooser-continuation-design.md b/implementation/docs-chooser-continuation-design.md| new file mode 100644 |
| index 0000000..8d48606 |
| --- /dev/null |
| +++ b/implementation/docs-chooser-continuation-design.md |
| @@ -0,0 +1,49 @@ |
| +# Docs chooser POST continuation amendment |
| + |
| +Status: approved as design only by configured Claude Fable (`claude-fable-5`, matching runner event metadata), 2026-09-08, exact design commit `95dcd682ac106ab725d9a85f141285709ab3659c`. Review exited 0 with APPROVE; artifacts `/tmp/oficina-chooser-continuation-review`. Based on reviewed chooser `07043915daebae10512a710915263093ed793b5b`, Home discussion 26. Branch discussion 27 records the verdict. This status annotation changes no approved design requirement. |
| + |
| +## Problem and evidence |
| + |
| +A successful team selection currently responds to a same-origin POST with a 303 to the selected Docs origin. With `form-action 'self'`, Chromium and WebKit block that external redirect. The POST has already reached Home, but the browser stays on the chooser. Adding every team origin to the form policy would grow that header with the fleet. |
| + |
| +The isolated experiment in `/tmp/oficina-home-meta-refresh-experiment` compared this control with a successful POST returning 200 HTML containing an immediate meta-refresh and an ordinary Continue link. Both engines, with JavaScript on and off, reached the exact external destination by GET for the latter. The escaped query separator survived. History grew by one; Back fetched the chooser with GET, with no POST replay or dialog. All four controls were blocked and all four continuation cases succeeded. These were synthetic same-site HTTPS origins with a fixture CSRF check and static team allowlist, not the production chooser or membership database. |
| + |
| +## Narrow response change |
| + |
| +Keep Entry, Select, session identity, method/path/query checks, bounded single-field form parsing, same-origin CSRF middleware and the current-membership re-query. Only a successful explicit selection changes its response from 303 to 200 continuation HTML. A team removed between listing and submission still receives the existing generic 404. A failed membership or destination read still receives the existing generic unavailable response. The continuation creates no session, cookie, remembered team, grant or durable navigation record. |
| + |
| +The generic GET entry with one team continues to return 303. Zero-team and multi-team GET pages remain unchanged. This amendment does not add routes, apply schema, change provisioning, or mount anything on the credential role. It does not revise signed-out/expired-session behavior; actual-router acceptance must exercise those paths too and report any existing CSP redirect problem instead of relaxing policy. |
| + |
| +Build the destination from the chosen current store result alone: the validated stored origin, fixed `handoff.StartPath`, and `url.Values` encoding fixed `return_to=/`. The request supplies only its bounded team sqid. It never supplies a hostname, resource path, return URL, display label or destination query. A stale rendered origin cannot override a newly read origin. |
| + |
| +## Destination and output boundary |
| + |
| +The production store remains responsible for its compiled app registry and authoritative provisioning destination policy. `directorydata.Choices` already validates a canonical HTTPS origin and invokes `ValidateRoute` for each live row; any invalid row refuses the whole result. The response builder must additionally reject a noncanonical origin before interpolating a result, including ports, userinfo, path, query, fragment, mixed-case host, controls, whitespace and non-DNS host syntax. Reuse a narrow exported origin validator from directorydata if needed; do not introduce a weaker parser or a second provisioning policy. Never infer permission from a shared suffix, same-site cookies, or arbitrary configured URL input. |
| + |
| +Only an origin admitted by that authoritative policy may be used. Same-site and cross-site destinations have the same validation requirement; being cross-site does not relax it. Synthetic cross-site browser tests will use a separately allowlisted fixture origin. This amendment authorizes no new production origin and no public-suffix wildcard. |
| + |
| +Use the existing Home renderer and layout with a default-empty head block. The continuation page alone supplies a meta refresh with literal delay zero and a destination string passed through Go `html/template` attribute escaping. The plain anchor receives the identical destination string through its normal URL-aware template context. Do not use `template.HTML`, `template.HTMLAttr`, `template.URL`, string-built markup, request URLs, or JavaScript. Assert rendered meta and anchor destinations are equal after HTML parsing, contain no template rejection marker, and match the computed canonical URL. The shared layout change must leave every other page without a refresh directive. |
| + |
| +The page uses the existing Estilo assets and Rastrillo components. Its title, heading and anchor use fixed generic copy, `Continue to Docs`; no team name, address or stored origin is printed as prose. The visible link is a real navigable anchor even when refresh is disabled. The user waived human copy review; ordinary writing and code review still apply before implementation. |
| + |
| +Respond with `Cache-Control: no-store`, `Content-Type: text/html; charset=utf-8` and `Referrer-Policy: no-referrer` before the buffered renderer writes the 200. The anchor additionally uses `referrerpolicy="no-referrer"`. Preserve the existing central Content Security Policy without adding any origins, wildcard or handler override. There is no Location or HTTP Refresh header. The content response is an explicit navigation handoff, never evidence of authorization at Docs. |
| + |
| +## Integration is separately gated |
| + |
| +The reviewed chooser remains unmounted. This amendment makes no runtime schema/store or configuration decision. A later integration design must explicitly settle: directory-role-only application of `directorydata.Schema`; the store's existing dedicated sealer and lifecycle; a compiled application registry with the exact Docs slug; the canonical team-origin and consumer-ID relationship enforced by provisioning and reads; directory session and exact-host middleware order; and absent/invalid configuration behavior. It must name the actual canonical destination formula or authoritative registry before code. It must not create duplicated per-team pilot environment configuration merely to mount this feature. Credential-role schema and routing stay unchanged. |
| + |
| +The consumer still verifies its own issuer, audience and account mapping and enforces item grants. Navigation does not claim team admission. Production activation requires separate review, actual integrated tests and canary browser evidence; this design approval alone authorizes none of those changes. |
| + |
| +## Acceptance evidence required before a code review |
| + |
| +Tests must preserve the existing zero/one/many, stale-membership, privacy and bounded-input coverage. Add a response test showing successful POST is 200, contains one immediate refresh and one exact matching anchor, uses the three headers above, and creates no cookie or Location header. Add hostile/noncanonical-origin tests at the response boundary, changed-origin re-query, removal between GET and POST, and a mutation that restores 303 and fails the intended browser check. Verify no other shared-renderer page gains a refresh directive. |
| + |
| +Actual `App.Handler` tests must use the real directory store, sessions and CSRF middleware under exact Host handling; test absent/foreign/sibling Origin, missing/invalid session, duplicate or unexpected form fields, wrong Host, credential role refusal and unavailable store. While production routes remain unmounted, an isolated test-only router hook may wire the real chooser under that same middleware; it must be visibly an overlay fixture and must not claim production routing is mounted. A later integration must repeat these against the actual mounted router. |
| + |
| +Run Chromium and WebKit with JavaScript on/off and narrow/wide layouts, using real browser HTTPS Host/Origin and the actual Home renderer and central CSP. Verify successful POST then external GET, exact destination, no CSP/page errors, no horizontal overflow, no referrer at the destination, Back returning chooser without POST replay, and removal refusal. Test separately allowlisted same-site and cross-site fixture destinations; no live private keys, email or synthetic live assertion. |
| + |
| +Prove fallback operation by intercepting the already rendered continuation response in the browser fixture and removing only the refresh element, recording that intervention. Then click the actual unmodified anchor and verify the same GET destination and no referrer. This demonstrates anchor operability when automatic refresh is absent, not a claim about every browser accessibility preference. Preserve normal immediate-refresh receipts separately. |
| + |
| +## Usability limits |
| + |
| +The immediate intermediate page may be too brief to read or focus. The plain link is for browsers or preferences that suppress automatic refresh; it is not a timed choice. The synthetic experiment observed useful Back behavior but did not cover screen readers, refresh-disabled browser preferences, network failure, reload or every browser version. Those limits must remain in the review and release record. If actual integrated tests find a navigation loop or POST replay, stop and amend the design rather than suppressing the observation. |
diff --git a/implementation/docs-team-chooser.md b/implementation/docs-team-chooser.md| new file mode 100644 |
| index 0000000..29925b6 |
| --- /dev/null |
| +++ b/implementation/docs-team-chooser.md |
| @@ -0,0 +1,15 @@ |
| +# Docs team chooser controller |
| + |
| +This unmounted slice follows approved directory designfa64e28 and store4c0d848. Discussion26 records review. It adds only handlers and templates: no production routing, directory schema mounting, provisioning, roster producer/client or item authority. The old bare Docs alias and its resource links are untouched. Guest/former-member directory tiles remain a separate requirement; this chooser lists current team memberships only. |
| + |
| +A directory App constructs the chooser with `newDocsChooser(store)`. Credential and legacy-role Apps are refused. The store interface is directorydata.Choices; its injected app registry/destination policy remains authoritative for allowed origins. Integration mounts Entry at GET /apps/docs and Select at POST /apps/docs/team under the existing directory sessions middleware and exact-host guard. The selection handler additionally carries the real same-origin CSRF middleware itself. Do not mount these handlers on the credential origin. |
| + |
| +Entry accepts no query parameters. It reads identity exclusively from sessions.Current and requires Home's opaque person-ref shape. Signed-out navigation redirects to the shared consumer with fixed return_to=/apps/docs; a test follows the actual credential issuer, callback, inbox and finish back to that route. No email, team selection, arbitrary return URL or document path is accepted as identity/navigation state. |
| + |
| +Zero live choices renders an honest empty state. One redirects to that validated stored origin's /auth/start with local return /. Several render ordinary POST buttons carrying only team sqid. Selection re-queries current memberships and compares the requested sqid; foreign, removed and unknown teams receive the same 404. Redirecting changes no session/cookie and grants no item access. Apps must still validate roster standing and individual grants after their own authentication. Two simultaneous tabs carry independent explicit POST selections. |
| + |
| +The existing Home renderer, layout, Estilo theme assets and Rastrillo attribute components are reused. Team names stay template-escaped; no script is added. Every controller response including redirects, method/query errors, CSRF rejection and storage failure is no-store. A failed read renders fixed text without stored names or origins. Runtime routing remains unchanged. |
| + |
| +Copy pipeline: gpt-5.6-luna drafted seven strings via the writing-pipeline skill; plain-writing and humane-docs passes made the failure message specific to loading teams rather than falsely declaring Docs down. Artifacts are in ignored writing/docs-chooser. The user waived human copy review for this work. No copy approval was requested or invented. |
| + |
| +Tests use the actual directory store/schema, actual Home renderer/session middleware and real shared handoff. They cover empty/one/many destinations, membership removal after rendering, foreign/unknown teams, escaped hostile labels, no script tags, exact fixed return, query/form bounds and duplicates, hostile Origin including the sibling credential origin, email-shaped session subject refusal, no-store and no new cookie, unavailable-store privacy, and constructor credential-role refusal. Existing full Home script/markup/privacy gates apply to the new templates. Two overlay mutations (ignoring the selected sqid, and discarding /apps/docs return) fail the intended tests. No live browser/canary claim is made for unmounted handlers. |
diff --git a/internal/home/app.go b/internal/home/app.go| index 1cdcf77..5e4be83 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -10,8 +10,8 @@ |
| // front page is still a placeholder (page.go). |
| // |
| // Explicit credential and directory roles now exchange browser-bound assertions. |
| -// Team selection, roster calls, provisioning and the rekey journal remain later |
| -// stages; a directory session establishes identity only. |
| +// A directory session establishes identity only. Membership-backed navigation |
| +// does not authorize access to application content. |
| package home |
| import ( |
| @@ -39,6 +39,7 @@ import ( |
| // it is sealed into. Nothing here reaches for a raw key. |
| type App struct { |
| runtime *Runtime |
| + directory *directoryFeatures |
| consumer *handoff.Consumer |
| origins Origins |
| credentialHost string |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index 6a8c854..2e3c8dc 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -17,7 +17,7 @@ import ( |
| ) |
| func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| - if cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil { |
| + if cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil { |
| return nil, errExchangeConfig |
| } |
| if logger == nil { |
| @@ -53,17 +53,25 @@ func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| if err != nil { |
| return nil, err |
| } |
| + a.directory, err = a.newDirectoryFeatures() |
| + if err != nil { |
| + return nil, err |
| + } |
| return a, nil |
| } |
| func (a *App) directoryRouter() http.Handler { |
| r := chi.NewRouter() |
| + r.Use(chooserNoStore) |
| r.Use(a.serveKnownHost) |
| + r.Use(a.chooserCookies) |
| r.Use(a.sessions.Middleware) |
| // This callback alone accepts a cross-origin POST, with its own exact issuer |
| // check and browser-bound finish. Every other mutation retains CSRF protection. |
| r.Post(handoff.CallbackPath, a.consumer.Callback) |
| r.Group(func(r chi.Router) { |
| r.Use(csrf.Protect(a.origins.Directory)) |
| + r.Get(docsEntryPath, a.directory.docs.Entry) |
| + r.Post(docsChoicePath, a.directory.docs.Select) |
| r.Get(handoff.StartPath, a.consumer.Start) |
| r.Get(handoff.FinishPath, a.consumer.Finish) |
| r.Get("/signin", func(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/home/directory_runtime.go b/internal/home/directory_runtime.go| new file mode 100644 |
| index 0000000..3d15c65 |
| --- /dev/null |
| +++ b/internal/home/directory_runtime.go |
| @@ -0,0 +1,84 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| +) |
| + |
| +type directoryApp struct { |
| + slug, name, foreignOrigin string |
| + integrated, provisionable bool |
| +} |
| + |
| +// The registry describes shipped integration, not team standing or readiness. |
| +// Provisioning remains disabled until the audited operator path is available. |
| +func directoryApps() []directoryApp { |
| + return []directoryApp{{slug: "docs", name: "Docs", integrated: true}} |
| +} |
| + |
| +func directoryRouteAllowed(route directorydata.Route) bool { |
| + for _, app := range directoryApps() { |
| + if app.slug == route.App && app.integrated { |
| + return route.Origin == "https://"+route.Label+"."+app.slug+".oficina.works" |
| + } |
| + } |
| + return false |
| +} |
| + |
| +type directoryFeatures struct { |
| + store *directorydata.Store |
| + docs *docsChooser |
| +} |
| + |
| +func (a *App) newDirectoryFeatures() (*directoryFeatures, error) { |
| + var apps []string |
| + for _, app := range directoryApps() { |
| + if app.integrated { |
| + apps = append(apps, app.slug) |
| + } |
| + } |
| + store, err := directorydata.New(a.db.Writer(), a.secrets.Sealer, directorydata.Config{Apps: apps, ValidateRoute: directoryRouteAllowed}) |
| + if err != nil { |
| + return nil, errExchangeConfig |
| + } |
| + chooser, err := a.newDocsChooser(store) |
| + if err != nil { |
| + return nil, errExchangeConfig |
| + } |
| + return &directoryFeatures{store: store, docs: chooser}, nil |
| +} |
| + |
| +func chooserPath(r *http.Request) bool { |
| + return r.URL.Path == docsEntryPath || r.URL.Path == docsChoicePath |
| +} |
| + |
| +// Place this before host and method routing so their refusals are not cached. |
| +func chooserNoStore(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if chooserPath(r) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// This narrow guard runs before session middleware can resolve the first cookie. |
| +// The existing assertion callback and every other route retain their behavior. |
| +func (a *App) chooserCookies(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if chooserPath(r) { |
| + count := 0 |
| + for _, cookie := range r.Cookies() { |
| + if cookie.Name == a.sessions.CookieName() { |
| + count++ |
| + } |
| + } |
| + if count > 1 { |
| + w.WriteHeader(http.StatusBadRequest) |
| + return |
| + } |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go| new file mode 100644 |
| index 0000000..98b466e |
| --- /dev/null |
| +++ b/internal/home/directory_runtime_test.go |
| @@ -0,0 +1,199 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func newMountedChooser(t *testing.T) chooserFixture { |
| + t.Helper() |
| + f := newChooserFixture(t) |
| + f.handler = f.p.directory.Router() |
| + return f |
| +} |
| + |
| +func TestDirectoryRuntimeMountUsesCanonicalRegistryAndCurrentMembership(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Code != 200 || !strings.Contains(r.Body.String(), "This account has no team with Docs available.") { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + noStore(t, r) |
| + id := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + f.team(t, "B2", "beta", "Foreign team", chooserOther) |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + noStore(t, r) |
| + for _, origin := range []string{"https://alpha.docs.oficina.works.evil.example", "https://evil-alpha.docs.oficina.works", "https://gamma.docs.oficina.works", "https://ALPHA.docs.oficina.works", "https://alpha.docs.oficina.works:443", "https://alpha.docs.oficina.works/path", "https://person@alpha.docs.oficina.works", "https://overnight-docs.bds.oncarlos.com"} { |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin=? WHERE team_id=?`, origin, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Code != 503 || r.Header().Get("Location") != "" || strings.Contains(r.Body.String(), origin) || strings.Contains(r.Body.String(), "Foreign team") { |
| + t.Fatal("unsafe stored destination", origin, r.Code) |
| + } |
| + noStore(t, r) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://alpha.docs.oficina.works' WHERE team_id=?`, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, id, chooserPerson); err != nil { |
| + t.Fatal(err) |
| + } |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Code != 200 || strings.Contains(r.Body.String(), "Foreign team") || r.Header().Get("Location") != "" { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| +} |
| + |
| +func TestDirectoryRuntimeNoStoreAndCookiesPrecedeRouterRefusals(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Private team", chooserPerson) |
| + cases := []struct { |
| + method, path, host, origin, fetch string |
| + cookies []*http.Cookie |
| + want int |
| + }{ |
| + {"GET", docsEntryPath, "evil.example", "", "", nil, 404}, |
| + {"DELETE", docsEntryPath, "", "", "", nil, 405}, |
| + {"GET", docsChoicePath, "", "", "", nil, 405}, |
| + {"GET", docsEntryPath + "?return_to=https://evil.example", "", "", "", nil, 404}, |
| + {"POST", docsChoicePath, "", "", "", []*http.Cookie{cookie}, 403}, |
| + {"POST", docsChoicePath, "", Origin, "", []*http.Cookie{cookie}, 403}, |
| + {"POST", docsChoicePath, "", directoryOrigin, "same-site", []*http.Cookie{cookie}, 403}, |
| + {"GET", docsEntryPath, "", "", "", []*http.Cookie{cookie, cookie}, 400}, |
| + {"POST", docsChoicePath, "", directoryOrigin, "", []*http.Cookie{cookie, {Name: cookie.Name, Value: "different"}}, 400}, |
| + } |
| + for _, tc := range cases { |
| + req := httptest.NewRequest(tc.method, directoryOrigin+tc.path, strings.NewReader("team=A1")) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + if tc.host != "" { |
| + req.Host = tc.host |
| + } |
| + if tc.origin != "" { |
| + req.Header.Set("Origin", tc.origin) |
| + } |
| + if tc.fetch != "" { |
| + req.Header.Set("Sec-Fetch-Site", tc.fetch) |
| + } |
| + for _, c := range tc.cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, req) |
| + if rec.Code != tc.want || rec.Header().Get("Cache-Control") != "no-store" || strings.Contains(rec.Body.String(), "Private team") { |
| + t.Fatalf("%s %s host=%s: %d %v", tc.method, tc.path, tc.host, rec.Code, rec.Header()) |
| + } |
| + if len(rec.Result().Cookies()) != 0 { |
| + t.Fatal("refusal changed cookies") |
| + } |
| + } |
| +} |
| + |
| +func TestDirectoryRuntimeAnonymousReturnAndRoleSchemaSeparation(t *testing.T) { |
| + f := newMountedChooser(t) |
| + r := f.request("GET", docsEntryPath, "", "", nil) |
| + if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + noStore(t, r) |
| + for _, tc := range []struct { |
| + app *App |
| + want int |
| + }{{f.p.directory, 3}, {f.p.credential.App, 0}, {newTestApp(t).App, 0}} { |
| + var count int |
| + if err := tc.app.db.Writer().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('teams','team_instances','memberships')`).Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != tc.want { |
| + t.Fatal("directory schema crossed roles", count, tc.want) |
| + } |
| + if tc.want == 0 { |
| + rec := exchangeRequest(tc.app, "GET", directoryOrigin+docsEntryPath, "", nil) |
| + if rec.Code != 404 { |
| + t.Fatal("chooser reachable in other role", rec.Code) |
| + } |
| + } |
| + } |
| + apps := directoryApps() |
| + if len(apps) != 1 || apps[0].slug != "docs" || !apps[0].integrated || apps[0].provisionable || apps[0].foreignOrigin != "" { |
| + t.Fatal("registry advertised unsupported provisioning") |
| + } |
| + apps[0].slug = "changed" |
| + if directoryApps()[0].slug != "docs" { |
| + t.Fatal("registry shared mutable backing") |
| + } |
| +} |
| + |
| +func TestDirectoryRuntimeExpiredChoiceReturnsToFreshTeamSelection(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + f.team(t, "B2", "beta", "Beta", chooserPerson) |
| + for _, metadata := range []string{"same-origin", "none"} { |
| + req := httptest.NewRequest("POST", directoryOrigin+docsChoicePath, strings.NewReader("team=B2")) |
| + req.AddCookie(cookie) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Sec-Fetch-Site", metadata) |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, req) |
| + assertDocsContinuation(t, rec, "https://beta.docs.oficina.works/auth/start?return_to=%2F") |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []*http.Cookie{cookie, nil} { |
| + r := f.request("POST", docsChoicePath, directoryOrigin, "team=SecretSubmittedTeamXYZ&return_to=https%3A%2F%2Fevil.example%2Fsecret-navigation", c) |
| + cleared := "" |
| + if c != nil { |
| + cleared = c.Name |
| + } |
| + assertChooserSigninContinuation(t, r, cleared) |
| + for _, forbidden := range []string{"SecretSubmittedTeamXYZ", "evil.example", "secret-navigation"} { |
| + if strings.Contains(r.Body.String(), forbidden) { |
| + t.Fatal("submitted selection reflected") |
| + } |
| + } |
| + } |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| + t.Fatal("anonymous GET changed", r.Code, r.Header()) |
| + } |
| + invalid := f.session(t, "email@example.invalid") |
| + for _, method := range []string{"GET", "POST"} { |
| + path := docsEntryPath |
| + if method == "POST" { |
| + path = docsChoicePath |
| + } |
| + r = f.request(method, path, directoryOrigin, "team=A1", invalid) |
| + if r.Code != 404 || strings.Contains(r.Body.String(), "http-equiv") { |
| + t.Fatal("invalid subject entered authentication continuation", r.Code) |
| + } |
| + noStore(t, r) |
| + } |
| +} |
| + |
| +func assertChooserSigninContinuation(t *testing.T, r *httptest.ResponseRecorder, cleared string) { |
| + t.Helper() |
| + cookies := r.Result().Cookies() |
| + if cleared == "" { |
| + if len(cookies) != 0 { |
| + t.Fatal("anonymous continuation minted state") |
| + } |
| + } else { |
| + if len(cookies) != 1 { |
| + t.Fatal("expired continuation did not only clear its session") |
| + } |
| + c := cookies[0] |
| + if c.Name != cleared || c.Value != "" || c.MaxAge >= 0 || c.Path != "/" || !c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode { |
| + t.Fatal("expired continuation replaced session state") |
| + } |
| + } |
| + assertContinuationPage(t, r, directoryOrigin+"/auth/start?return_to=%2Fapps%2Fdocs") |
| +} |
diff --git a/internal/home/directorydata/migrations/0001_directory.sql b/internal/home/directorydata/migrations/0001_directory.sql| new file mode 100644 |
| index 0000000..dfc651e |
| --- /dev/null |
| +++ b/internal/home/directorydata/migrations/0001_directory.sql |
| @@ -0,0 +1,23 @@ |
| +CREATE TABLE teams ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + sqid TEXT NOT NULL UNIQUE CHECK(length(sqid) BETWEEN 1 AND 64 AND sqid NOT GLOB '*[^a-zA-Z0-9]*'), |
| + label TEXT NOT NULL UNIQUE CHECK(length(label) BETWEEN 1 AND 63 AND label NOT GLOB '*[^a-z0-9-]*' AND substr(label,1,1) != '-' AND substr(label,-1,1) != '-'), |
| + sealed_name BLOB NOT NULL CHECK(typeof(sealed_name) = 'blob' AND length(sealed_name) BETWEEN 29 AND 284), |
| + created_at INTEGER NOT NULL CHECK(created_at > 0), |
| + roster_epoch INTEGER NOT NULL DEFAULT 1 CHECK(typeof(roster_epoch) = 'integer' AND roster_epoch > 0) |
| +); |
| +CREATE TABLE team_instances ( |
| + team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, |
| + app TEXT NOT NULL, |
| + origin TEXT NOT NULL UNIQUE, |
| + consumer_id TEXT NOT NULL UNIQUE, |
| + state TEXT NOT NULL CHECK(state IN ('pending', 'live', 'disabled')), |
| + PRIMARY KEY(team_id, app) |
| +); |
| +CREATE TABLE memberships ( |
| + team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, |
| + person_ref TEXT NOT NULL CHECK(length(person_ref) = 29 AND substr(person_ref,1,3) = 'pr_' AND substr(person_ref,4) NOT GLOB '*[^a-z2-7]*'), |
| + role TEXT NOT NULL CHECK(role IN ('owner', 'member')), |
| + PRIMARY KEY(team_id, person_ref) |
| +); |
| +CREATE INDEX memberships_person ON memberships(person_ref, team_id); |
diff --git a/internal/home/directorydata/store.go b/internal/home/directorydata/store.go| new file mode 100644 |
| index 0000000..c7bb31b |
| --- /dev/null |
| +++ b/internal/home/directorydata/store.go |
| @@ -0,0 +1,254 @@ |
| +// Package directorydata holds directory-owned team records. Its read methods |
| +// establish navigation choices only; applications still decide item access. |
| +package directorydata |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "embed" |
| + "errors" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "unicode" |
| + "unicode/utf8" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| + |
| +// Schema belongs in the directory role's schema, never the credential schema. |
| +// It is not mounted by this package. |
| +var Schema = migrate.MustFromFS(migrations, "oficina_directory") |
| + |
| +var ( |
| + ErrInvalid = errors.New("invalid directory configuration or input") |
| + ErrUnavailable = errors.New("directory unavailable") |
| + ErrNotFound = errors.New("directory team not found") |
| +) |
| + |
| +// Sealer must authenticate ciphertext and separate purposes. |
| +type Sealer interface { |
| + Seal(string, []byte) ([]byte, error) |
| + Open(string, []byte) ([]byte, error) |
| +} |
| + |
| +// Route describes a stored destination for the caller's provisioning policy. |
| +type Route struct { |
| + TeamID int64 |
| + TeamSQID, Label, App, Origin, ConsumerID string |
| +} |
| + |
| +// Config supplies the compiled application registry and destination policy. |
| +// ValidateRoute must not authorize a destination from request input. Production |
| +// can check the prescribed team hostname; an isolated pilot can use exact pins. |
| +type Config struct { |
| + Apps []string |
| + ValidateRoute func(Route) bool |
| +} |
| + |
| +type Team struct { |
| + ID int64 |
| + SQID, Label, Name, Role string |
| +} |
| + |
| +type Choice struct { |
| + Team |
| + App, Origin, ConsumerID string |
| +} |
| + |
| +type Store struct { |
| + db *sql.DB |
| + sealer Sealer |
| + apps map[string]bool |
| + validateRoute func(Route) bool |
| +} |
| + |
| +func New(db *sql.DB, sealer Sealer, cfg Config) (*Store, error) { |
| + if db == nil || sealer == nil || cfg.ValidateRoute == nil || len(cfg.Apps) == 0 || len(cfg.Apps) > 100 { |
| + return nil, ErrInvalid |
| + } |
| + s := &Store{db: db, sealer: sealer, apps: make(map[string]bool, len(cfg.Apps)), validateRoute: cfg.ValidateRoute} |
| + for _, app := range cfg.Apps { |
| + if !validLabel(app) || s.apps[app] { |
| + return nil, ErrInvalid |
| + } |
| + s.apps[app] = true |
| + } |
| + return s, nil |
| +} |
| + |
| +// SealTeamName prepares a name for an explicitly authorized caller-owned write |
| +// transaction. It neither inserts a team nor creates a membership. The caller |
| +// must reserve a new, immutable team ID before sealing and commit both together. |
| +func (s *Store) SealTeamName(teamID int64, name string) ([]byte, error) { |
| + if teamID <= 0 || !validName(name) { |
| + return nil, ErrInvalid |
| + } |
| + b, err := s.sealer.Seal(namePurpose(teamID), []byte(name)) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return b, nil |
| +} |
| + |
| +// TeamForMember does not distinguish an unknown team from another person's team. |
| +func (s *Store) TeamForMember(ctx context.Context, subject, sqid string) (Team, error) { |
| + if !validSubject(subject) || !alnum(sqid, 64) { |
| + return Team{}, ErrNotFound |
| + } |
| + var t Team |
| + var sealed []byte |
| + err := s.db.QueryRowContext(ctx, `SELECT t.id,t.sqid,t.label,t.sealed_name,m.role FROM teams t JOIN memberships m ON m.team_id=t.id WHERE t.sqid=? AND m.person_ref=?`, sqid, subject).Scan(&t.ID, &t.SQID, &t.Label, &sealed, &t.Role) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return Team{}, ErrNotFound |
| + } |
| + if err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + if err = s.openTeam(&t, sealed); err != nil { |
| + return Team{}, err |
| + } |
| + return t, nil |
| +} |
| + |
| +// Choices returns only current memberships with live instances, oldest team |
| +// first. Invalid stored configuration refuses the whole result, never a prefix. |
| +func (s *Store) Choices(ctx context.Context, subject, app string) ([]Choice, error) { |
| + if !validSubject(subject) || !s.apps[app] { |
| + return nil, ErrInvalid |
| + } |
| + rows, err := s.db.QueryContext(ctx, `SELECT t.id,t.sqid,t.label,t.sealed_name,m.role,i.app,i.origin,i.consumer_id,i.state FROM memberships m JOIN teams t ON t.id=m.team_id JOIN team_instances i ON i.team_id=t.id WHERE m.person_ref=? AND i.app=? ORDER BY t.id LIMIT 10001`, subject, app) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + defer rows.Close() |
| + choices := make([]Choice, 0) |
| + count := 0 |
| + for rows.Next() { |
| + count++ |
| + if count > 10000 { |
| + return nil, ErrUnavailable |
| + } |
| + var c Choice |
| + var sealed []byte |
| + var state string |
| + if rows.Scan(&c.ID, &c.SQID, &c.Label, &sealed, &c.Role, &c.App, &c.Origin, &c.ConsumerID, &state) != nil { |
| + return nil, ErrUnavailable |
| + } |
| + if state != "live" && state != "pending" && state != "disabled" { |
| + return nil, ErrUnavailable |
| + } |
| + if state != "live" { |
| + continue |
| + } |
| + route := Route{TeamID: c.ID, TeamSQID: c.SQID, Label: c.Label, App: c.App, Origin: c.Origin, ConsumerID: c.ConsumerID} |
| + if !ValidOrigin(c.Origin) || !validConsumer(c.ConsumerID) || !s.validateRoute(route) { |
| + return nil, ErrUnavailable |
| + } |
| + if err = s.openTeam(&c.Team, sealed); err != nil { |
| + return nil, err |
| + } |
| + choices = append(choices, c) |
| + } |
| + if rows.Err() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return choices, nil |
| +} |
| + |
| +func (s *Store) openTeam(t *Team, sealed []byte) error { |
| + if t.ID <= 0 || !alnum(t.SQID, 64) || !validLabel(t.Label) || (t.Role != "owner" && t.Role != "member") { |
| + return ErrUnavailable |
| + } |
| + b, err := s.sealer.Open(namePurpose(t.ID), sealed) |
| + if err != nil || !validName(string(b)) { |
| + return ErrUnavailable |
| + } |
| + t.Name = string(b) |
| + return nil |
| +} |
| +func namePurpose(id int64) string { return "directory.team-name/" + strconv.FormatInt(id, 10) } |
| + |
| +func alnum(s string, max int) bool { |
| + if len(s) == 0 || len(s) > max { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validSubject(s string) bool { |
| + if len(s) != 29 || !strings.HasPrefix(s, "pr_") { |
| + return false |
| + } |
| + for _, c := range s[3:] { |
| + if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validLabel(s string) bool { |
| + if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validName(s string) bool { |
| + if len(s) == 0 || len(s) > 256 || !utf8.ValidString(s) || strings.TrimSpace(s) == "" { |
| + return false |
| + } |
| + for _, r := range s { |
| + if unicode.IsControl(r) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// ValidOrigin accepts only canonical HTTPS DNS origins without ports or URL components. |
| +// It checks syntax only; callers must also apply their provisioning policy. |
| +func ValidOrigin(s string) bool { |
| + u, err := url.Parse(s) |
| + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery || u.Opaque != "" || u.Port() != "" || u.Host != strings.ToLower(u.Host) { |
| + return false |
| + } |
| + parts := strings.Split(u.Host, ".") |
| + if len(parts) < 2 { |
| + return false |
| + } |
| + for _, p := range parts { |
| + if len(p) == 0 || len(p) > 63 || p[0] == '-' || p[len(p)-1] == '-' { |
| + return false |
| + } |
| + for _, c := range p { |
| + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { |
| + return false |
| + } |
| + } |
| + } |
| + return len(u.Host) <= 253 |
| +} |
| + |
| +func validConsumer(s string) bool { |
| + if len(s) == 0 || len(s) > 64 { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/home/directorydata/store_test.go b/internal/home/directorydata/store_test.go| new file mode 100644 |
| index 0000000..c84b7bb |
| --- /dev/null |
| +++ b/internal/home/directorydata/store_test.go |
| @@ -0,0 +1,255 @@ |
| +package directorydata |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +const personA = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| +const personB = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| +const privateName = "CANARY-TEAM-NAME-2b71" |
| + |
| +func fixture(t *testing.T) (*Store, *db.DB, string) { |
| + t.Helper() |
| + p := filepath.Join(t.TempDir(), "directory.db") |
| + d, e := db.Open(p, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, e = migrate.Apply(context.Background(), d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + sealer, e := seal.New(bytes.Repeat([]byte{17}, 32)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + s, e := New(d.Writer(), sealer, Config{Apps: []string{"docs", "sheets"}, ValidateRoute: func(r Route) bool { return r.Origin == "https://"+r.Label+"."+r.App+".oficina.works" }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return s, d, p |
| +} |
| +func exec(t *testing.T, d *sql.DB, q string, args ...any) { |
| + t.Helper() |
| + if _, e := d.Exec(q, args...); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| + |
| +// Only test setup performs these explicit operator-owned writes. |
| +func team(t *testing.T, s *Store, sqid, label, name string) int64 { |
| + t.Helper() |
| + tx, e := s.db.Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + r, e := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + id, e := r.LastInsertId() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b, e := s.SealTeamName(id, name) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = tx.Exec(`UPDATE teams SET sealed_name=? WHERE id=?`, b, id); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + return id |
| +} |
| +func member(t *testing.T, s *Store, id int64, p, role string) { |
| + t.Helper() |
| + exec(t, s.db, `INSERT INTO memberships VALUES(?,?,?)`, id, p, role) |
| +} |
| +func instance(t *testing.T, s *Store, id int64, label, app, state string) { |
| + t.Helper() |
| + exec(t, s.db, `INSERT INTO team_instances VALUES(?,?,?,?,?)`, id, app, "https://"+label+"."+app+".oficina.works", label+"-"+app, state) |
| +} |
| +func TestIsolationRemovalAndNoAutomaticOwner(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + ctx := context.Background() |
| + a := team(t, s, "A1", "alpha", privateName) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + instance(t, s, a, "alpha", "docs", "live") |
| + instance(t, s, b, "beta", "docs", "live") |
| + instance(t, s, a, "alpha", "sheets", "pending") |
| + for _, p := range []string{personA, personB} { |
| + got, e := s.Choices(ctx, p, "docs") |
| + if e != nil || len(got) != 0 { |
| + t.Fatal(got, e) |
| + } |
| + } |
| + member(t, s, a, personA, "owner") |
| + member(t, s, b, personB, "member") |
| + got, e := s.Choices(ctx, personA, "docs") |
| + if e != nil || len(got) != 1 || got[0].ID != a || got[0].Name != privateName || got[0].Role != "owner" { |
| + t.Fatal(got, e) |
| + } |
| + for _, sqid := range []string{"B2", "Missing"} { |
| + if _, e = s.TeamForMember(ctx, personA, sqid); !errors.Is(e, ErrNotFound) { |
| + t.Fatal(e) |
| + } |
| + } |
| + if got, e = s.Choices(ctx, personA, "sheets"); e != nil || len(got) != 0 { |
| + t.Fatal(got, e) |
| + } |
| + member(t, s, b, personA, "member") |
| + got, e = s.Choices(ctx, personA, "docs") |
| + if e != nil || len(got) != 2 || got[0].ID != a || got[1].ID != b { |
| + t.Fatal(got, e) |
| + } |
| + exec(t, s.db, `DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, personA) |
| + if _, e = s.TeamForMember(ctx, personA, "A1"); !errors.Is(e, ErrNotFound) { |
| + t.Fatal(e) |
| + } |
| + got, e = s.Choices(ctx, personA, "docs") |
| + if e != nil || len(got) != 1 || got[0].ID != b { |
| + t.Fatal(got, e) |
| + } |
| + exec(t, s.db, `UPDATE team_instances SET state='disabled' WHERE team_id=?`, b) |
| + if got, e = s.Choices(ctx, personA, "docs"); e != nil || len(got) != 0 { |
| + t.Fatal(got, e) |
| + } |
| +} |
| +func TestSealedNamePrivacyAndBinding(t *testing.T) { |
| + s, _, p := fixture(t) |
| + a := team(t, s, "A1", "alpha", privateName) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + member(t, s, a, personA, "member") |
| + member(t, s, b, personA, "member") |
| + sealtest.AssertNoPlaintext(t, p) |
| + var ciphertext []byte |
| + if e := s.db.QueryRow(`SELECT sealed_name FROM teams WHERE id=?`, a).Scan(&ciphertext); e != nil { |
| + t.Fatal(e) |
| + } |
| + exec(t, s.db, `UPDATE teams SET sealed_name=? WHERE id=?`, ciphertext, b) |
| + _, e := s.TeamForMember(context.Background(), personA, "B2") |
| + if !errors.Is(e, ErrUnavailable) || strings.Contains(e.Error(), privateName) { |
| + t.Fatal(e) |
| + } |
| + ciphertext[len(ciphertext)-1] ^= 1 |
| + exec(t, s.db, `UPDATE teams SET sealed_name=? WHERE id=?`, ciphertext, a) |
| + if _, e = s.TeamForMember(context.Background(), personA, "A1"); !errors.Is(e, ErrUnavailable) { |
| + t.Fatal(e) |
| + } |
| + sealtest.AssertNoPlaintext(t, p) |
| +} |
| +func TestSchemaStableIDsConstraintsAndPrivacy(t *testing.T) { |
| + s, d, _ := fixture(t) |
| + a := team(t, s, "A1", "alpha", privateName) |
| + exec(t, s.db, `DELETE FROM teams WHERE id=?`, a) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + if b <= a { |
| + t.Fatalf("reused ID %d %d", a, b) |
| + } |
| + if _, e := migrate.Apply(context.Background(), d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, q := range []struct { |
| + s string |
| + a []any |
| + }{ |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{b, "owner@example.invalid", "owner"}}, |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{b, personA, "admin"}}, |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{a, personA, "owner"}}, |
| + {`UPDATE teams SET roster_epoch=0`, nil}, |
| + {`UPDATE teams SET sealed_name=?`, []any{privateName}}, |
| + {`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES('B2','gamma',zeroblob(29),1)`, nil}, |
| + } { |
| + if _, e := s.db.Exec(q.s, q.a...); e == nil { |
| + t.Fatalf("accepted %s", q.s) |
| + } |
| + } |
| + rows, e := s.db.Query(`SELECT sql FROM sqlite_master WHERE type='table'`) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + var ddl string |
| + if e = rows.Scan(&ddl); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, forbidden := range []string{"email", "address", "credentials", "CREATE TABLE people", "CREATE TABLE sessions"} { |
| + if strings.Contains(ddl, forbidden) { |
| + t.Fatalf("forbidden schema %s", forbidden) |
| + } |
| + } |
| + } |
| +} |
| +func TestUnsafeStoredRouteRefusesEntireList(t *testing.T) { |
| + for _, mutation := range []string{ |
| + `UPDATE team_instances SET origin='https://attacker.example' WHERE team_id=2`, |
| + `UPDATE team_instances SET origin='https://beta.docs.oficina.works/path' WHERE team_id=2`, |
| + `UPDATE team_instances SET consumer_id='bad@identity' WHERE team_id=2`, |
| + `PRAGMA ignore_check_constraints=ON; UPDATE team_instances SET state='unknown' WHERE team_id=2`, |
| + } { |
| + t.Run(mutation, func(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + a := team(t, s, "A1", "alpha", privateName) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + for i, id := range []int64{a, b} { |
| + member(t, s, id, personA, "member") |
| + instance(t, s, id, []string{"alpha", "beta"}[i], "docs", "live") |
| + } |
| + exec(t, s.db, mutation) |
| + got, e := s.Choices(context.Background(), personA, "docs") |
| + if !errors.Is(e, ErrUnavailable) || got != nil { |
| + t.Fatal(got, e) |
| + } |
| + }) |
| + } |
| +} |
| +func TestInputAndErrorPrivacy(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + for _, origin := range []string{"http://alpha.example", "https://user@example.com", "https://example.com/", "https://example.com?", "https://EXAMPLE.com", "https://example.com:443", "https://example.com#x", "https://example.com.", "https://localhost", "https://a..example"} { |
| + if ValidOrigin(origin) { |
| + t.Errorf("accepted %q", origin) |
| + } |
| + } |
| + if !ValidOrigin("https://alpha.docs.oficina.works") { |
| + t.Fatal("valid origin refused") |
| + } |
| + for _, cfg := range []Config{{}, {Apps: []string{"docs"}}, {Apps: []string{"docs", "docs"}, ValidateRoute: func(Route) bool { return true }}, {Apps: []string{"bad/app"}, ValidateRoute: func(Route) bool { return true }}} { |
| + if _, e := New(s.db, s.sealer, cfg); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + } |
| + for _, name := range []string{"", strings.Repeat("x", 257), "\x00private", string([]byte{255}), " "} { |
| + if _, e := s.SealTeamName(1, name); !errors.Is(e, ErrInvalid) || strings.Contains(e.Error(), "private") { |
| + t.Fatal(e) |
| + } |
| + } |
| + if _, e := s.SealTeamName(0, privateName); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + if _, e := s.Choices(context.Background(), personA, "unknown"); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + if _, e := s.Choices(context.Background(), "person@example.invalid", "docs"); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + s.db.Close() |
| + _, e := s.Choices(context.Background(), personA, "docs") |
| + if !errors.Is(e, ErrUnavailable) || strings.Contains(e.Error(), privateName) { |
| + t.Fatal(e) |
| + } |
| +} |
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go| new file mode 100644 |
| index 0000000..53c4136 |
| --- /dev/null |
| +++ b/internal/home/docs_chooser.go |
| @@ -0,0 +1,159 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "mime" |
| + "net/http" |
| + "net/url" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const docsEntryPath = "/apps/docs" |
| +const docsChoicePath = "/apps/docs/team" |
| + |
| +type docsChoices interface { |
| + Choices(context.Context, string, string) ([]directorydata.Choice, error) |
| +} |
| + |
| +type docsChooser struct { |
| + app *App |
| + store docsChoices |
| + selectHandler http.Handler |
| +} |
| + |
| +// newDocsChooser prepares handlers without mounting routes or applying schema. |
| +// The parent directory router must supply its existing sessions middleware and |
| +// exact-host guard. Selection carries its own same-origin CSRF protection. |
| +func (a *App) newDocsChooser(store docsChoices) (*docsChooser, error) { |
| + if a == nil || a.runtime == nil || a.runtime.role != "directory" || a.renderer == nil || a.origins.validate() != nil || store == nil { |
| + return nil, errors.New("invalid Docs chooser configuration") |
| + } |
| + c := &docsChooser{app: a, store: store} |
| + c.selectHandler = csrf.Protect(a.origins.Directory)(http.HandlerFunc(c.choose)) |
| + return c, nil |
| +} |
| +func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method != http.MethodGet { |
| + w.Header().Set("Allow", http.MethodGet) |
| + w.WriteHeader(http.StatusMethodNotAllowed) |
| + return |
| + } |
| + if r.URL.Path != docsEntryPath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + subject, ok := c.subject(w, r) |
| + if !ok { |
| + return |
| + } |
| + choices, err := c.store.Choices(r.Context(), subject, "docs") |
| + if err != nil { |
| + c.unavailable(w) |
| + return |
| + } |
| + switch len(choices) { |
| + case 0: |
| + c.app.render(w, http.StatusOK, "docs_empty.html", nil) |
| + case 1: |
| + c.redirect(w, r, choices[0]) |
| + default: |
| + c.app.render(w, http.StatusOK, "docs_chooser.html", struct{ Choices []directorydata.Choice }{choices}) |
| + } |
| +} |
| +func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if r.Method != http.MethodPost { |
| + w.Header().Set("Allow", http.MethodPost) |
| + w.WriteHeader(http.StatusMethodNotAllowed) |
| + return |
| + } |
| + if r.URL.Path != docsChoicePath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + c.selectHandler.ServeHTTP(w, r) |
| +} |
| +func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) { |
| + subject, ok := c.subject(w, r) |
| + if !ok { |
| + return |
| + } |
| + media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) |
| + if err != nil || media != "application/x-www-form-urlencoded" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024)) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + form, err := url.ParseQuery(string(body)) |
| + if err != nil || len(form) != 1 || len(form["team"]) != 1 || form.Get("team") == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + choices, err := c.store.Choices(r.Context(), subject, "docs") |
| + if err != nil { |
| + c.unavailable(w) |
| + return |
| + } |
| + for _, choice := range choices { |
| + if choice.SQID == form.Get("team") { |
| + c.continueToDocs(w, choice) |
| + return |
| + } |
| + } |
| + http.NotFound(w, r) |
| +} |
| +func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + session, ok := sessions.Current(r) |
| + if !ok { |
| + target := handoff.StartPath + "?" + url.Values{"return_to": {docsEntryPath}}.Encode() |
| + if r.Method == http.MethodPost { |
| + // Start a new GET navigation after the form response. The submitted |
| + // selection is discarded; sign-in returns to current team choices. |
| + c.renderContinuation(w, c.app.origins.Directory+target) |
| + } else { |
| + http.Redirect(w, r, target, http.StatusSeeOther) |
| + } |
| + return "", false |
| + } |
| + if !ValidPersonRef(session.Subject) { |
| + http.NotFound(w, r) |
| + return "", false |
| + } |
| + return session.Subject, true |
| +} |
| +func (c *docsChooser) unavailable(w http.ResponseWriter) { |
| + c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil) |
| +} |
| +func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) { |
| + // The directorydata store has already applied the configured destination |
| + // policy. Generic entry carries only the app root, never a document path. |
| + http.Redirect(w, r, choice.Origin+handoff.StartPath+"?"+url.Values{"return_to": {"/"}}.Encode(), http.StatusSeeOther) |
| +} |
| + |
| +func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) { |
| + if !directorydata.ValidOrigin(choice.Origin) { |
| + c.unavailable(w) |
| + return |
| + } |
| + destination := choice.Origin + handoff.StartPath + "?" + url.Values{"return_to": {"/"}}.Encode() |
| + c.renderContinuation(w, destination) |
| +} |
| + |
| +// Callers supply either a validated stored app origin plus the fixed app path, |
| +// or the validated directory origin plus the fixed sign-in continuation path. |
| +func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| + c.app.render(w, http.StatusOK, "docs_continue.html", struct{ Destination string }{destination}) |
| +} |
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go| new file mode 100644 |
| index 0000000..3bfc673 |
| --- /dev/null |
| +++ b/internal/home/docs_chooser_test.go |
| @@ -0,0 +1,261 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const chooserPerson = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| +const chooserOther = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| + |
| +type chooserFixture struct { |
| + p exchangePair |
| + store *directorydata.Store |
| + handler http.Handler |
| +} |
| + |
| +func newChooserFixture(t *testing.T) chooserFixture { |
| + t.Helper() |
| + p := newExchangePair(t) |
| + if _, err := migrate.Apply(context.Background(), p.directory.db, directorydata.Schema); err != nil { |
| + t.Fatal(err) |
| + } |
| + store, err := directorydata.New(p.directory.db.Writer(), p.directory.secrets.Sealer, directorydata.Config{Apps: []string{"docs"}, ValidateRoute: func(r directorydata.Route) bool { return r.Origin == "https://"+r.Label+".docs.oficina.works" }}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := p.directory.newDocsChooser(store) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mux := http.NewServeMux() |
| + mux.HandleFunc(docsEntryPath, c.Entry) |
| + mux.HandleFunc(docsChoicePath, c.Select) |
| + return chooserFixture{p, store, p.directory.serveKnownHost(p.directory.sessions.Middleware(mux))} |
| +} |
| +func (f chooserFixture) session(t *testing.T, subject string) *http.Cookie { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("GET", directoryOrigin+"/", nil) |
| + if err := f.p.directory.sessions.SignIn(rec, req, sessions.Session{Subject: subject, Method: "assertion", AuthTime: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + cookie := sessionCookie(rec) |
| + if cookie == nil { |
| + t.Fatal("missing session") |
| + } |
| + return cookie |
| +} |
| +func (f chooserFixture) request(method, path, origin, body string, cookie *http.Cookie) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(method, directoryOrigin+path, strings.NewReader(body)) |
| + if cookie != nil { |
| + r.AddCookie(cookie) |
| + } |
| + if method == "POST" { |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + } |
| + if origin != "" { |
| + r.Header.Set("Origin", origin) |
| + } |
| + rec := httptest.NewRecorder() |
| + f.handler.ServeHTTP(rec, r) |
| + return rec |
| +} |
| +func (f chooserFixture) team(t *testing.T, sqid, label, name, person string) int64 { |
| + t.Helper() |
| + tx, err := f.p.directory.db.Writer().Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer tx.Rollback() |
| + r, err := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := r.LastInsertId() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := f.store.SealTeamName(id, name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, q := range []struct { |
| + s string |
| + a []any |
| + }{ |
| + {`UPDATE teams SET sealed_name=? WHERE id=?`, []any{b, id}}, |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{id, person, "member"}}, |
| + {`INSERT INTO team_instances VALUES(?,?,?,?,?)`, []any{id, "docs", "https://" + label + ".docs.oficina.works", label + "-docs", "live"}}, |
| + } { |
| + if _, err = tx.Exec(q.s, q.a...); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err = tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return id |
| +} |
| +func noStore(t *testing.T, r *httptest.ResponseRecorder) { |
| + t.Helper() |
| + if r.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("cacheable response %d", r.Code) |
| + } |
| + if len(r.Result().Cookies()) != 0 { |
| + t.Fatal("chooser changed session/navigation cookies") |
| + } |
| +} |
| +func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 200 || !strings.Contains(r.Body.String(), "This account has no team with Docs available.") { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + a := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson) |
| + f.team(t, "C3", "foreign", "Foreign secret label", chooserOther) |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + for _, want := range []string{`method="post" action="/apps/docs/team"`, `name="team" value="A1"`, `name="team" value="B2"`, `/static/theme.css`, `<script>`} { |
| + if !strings.Contains(r.Body.String(), want) { |
| + t.Fatal("missing", want) |
| + } |
| + } |
| + if r.Code != 200 || strings.Contains(r.Body.String(), "<script") || strings.Contains(r.Body.String(), "Foreign secret label") || strings.Contains(r.Body.String(), chooserPerson) { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie) |
| + noStore(t, r) |
| + assertDocsContinuation(t, r, "https://beta.docs.oficina.works/auth/start?return_to=%2F") |
| + if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, team := range []string{"A1", "C3", "Unknown"} { |
| + r = f.request("POST", docsChoicePath, directoryOrigin, "team="+team, cookie) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(team, r.Code) |
| + } |
| + } |
| + r = f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal(r.Header()) |
| + } |
| +} |
| +func TestDocsChooserSignedOutReturnThroughActualHandoff(t *testing.T) { |
| + f := newChooserFixture(t) |
| + r := f.request("GET", docsEntryPath, "", "", nil) |
| + noStore(t, r) |
| + if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + start := exchangeRequest(f.p.directory, "GET", directoryOrigin+r.Header().Get("Location"), "", nil) |
| + pending := namedCookie(t, start, "__Host-oficina-assertion-pending") |
| + authorize := exchangeRequest(f.p.credential.App, "GET", start.Header().Get("Location"), "", nil) |
| + intent := namedCookie(t, authorize, assertionIntentCookie) |
| + credentialSession := signInFully(t, f.p.credential, "chooser@example.invalid") |
| + token, _ := f.p.mint(t, intent, credentialSession) |
| + callback := exchangeRequest(f.p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}}) |
| + inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox") |
| + finish := exchangeRequest(f.p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox) |
| + if finish.Code != 303 || finish.Header().Get("Location") != docsEntryPath { |
| + t.Fatal(finish.Code, finish.Header()) |
| + } |
| + r = f.request("GET", finish.Header().Get("Location"), "", "", sessionCookie(finish)) |
| + if r.Code != 200 { |
| + t.Fatal(r.Code) |
| + } |
| +} |
| +func TestDocsChooserRefusesHostileInputAndCrossOriginSelection(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + for _, path := range []string{"/apps/docs?team=A1", "/apps/docs?return_to=https://attacker.example", "/apps/docs?", "/apps/docs/team?team=A1"} { |
| + method := "GET" |
| + if strings.Contains(path, "/team") { |
| + method = "POST" |
| + } |
| + r := f.request(method, path, directoryOrigin, "team=A1", cookie) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(path, r.Code) |
| + } |
| + } |
| + for _, body := range []string{"team=A1&team=A1", "team=A1&return_to=%2Fsecret", "team=", "team=%zz", "team=https%3A%2F%2Fattacker.example", strings.Repeat("x", 1025)} { |
| + r := f.request("POST", docsChoicePath, directoryOrigin, body, cookie) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(body, r.Code) |
| + } |
| + } |
| + for _, origin := range []string{"", "https://attacker.example", Origin} { |
| + r := f.request("POST", docsChoicePath, origin, "team=A1", cookie) |
| + noStore(t, r) |
| + if r.Code != 403 { |
| + t.Fatal(origin, r.Code) |
| + } |
| + } |
| + for _, subject := range []string{"user@example.invalid", "A1", chooserOther} { |
| + other := f.session(t, subject) |
| + r := f.request("POST", docsChoicePath, directoryOrigin, "team=A1", other) |
| + noStore(t, r) |
| + if r.Code != 404 { |
| + t.Fatal(subject, r.Code) |
| + } |
| + } |
| + r := f.request("GET", docsChoicePath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 405 { |
| + t.Fatal(r.Code) |
| + } |
| + r = f.request("POST", docsEntryPath, directoryOrigin, "team=A1", cookie) |
| + noStore(t, r) |
| + if r.Code != 405 { |
| + t.Fatal(r.Code) |
| + } |
| +} |
| +func TestDocsChooserFailureIsPrivateWhenMounted(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Private team name", chooserPerson) |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://unapproved.example'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + noStore(t, r) |
| + if r.Code != 503 || strings.Contains(r.Body.String(), "Private team name") || strings.Contains(r.Body.String(), "unapproved.example") { |
| + t.Fatal(r.Code, r.Body.String()) |
| + } |
| + r = exchangeRequest(f.p.directory, "GET", directoryOrigin+docsEntryPath, "", nil, cookie) |
| + if r.Code != 503 { |
| + t.Fatal("mounted chooser did not preserve storage refusal", r.Code) |
| + } |
| +} |
| + |
| +func TestDocsChooserCannotAttachToCredentialRole(t *testing.T) { |
| + f := newChooserFixture(t) |
| + if _, err := f.p.credential.App.newDocsChooser(f.store); err == nil { |
| + t.Fatal("credential role accepted directory renderer") |
| + } |
| + if _, err := f.p.directory.newDocsChooser(nil); err == nil { |
| + t.Fatal("nil store accepted") |
| + } |
| +} |
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go| new file mode 100644 |
| index 0000000..549afd2 |
| --- /dev/null |
| +++ b/internal/home/docs_continue_test.go |
| @@ -0,0 +1,149 @@ |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/xml" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| +) |
| + |
| +func continuationTargets(t *testing.T, body string) (refresh, links []string) { |
| + t.Helper() |
| + d := xml.NewDecoder(strings.NewReader(body)) |
| + d.Strict = false |
| + d.AutoClose = xml.HTMLAutoClose |
| + d.Entity = xml.HTMLEntity |
| + for { |
| + token, err := d.Token() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + el, ok := token.(xml.StartElement) |
| + if !ok { |
| + continue |
| + } |
| + a := map[string]string{} |
| + for _, v := range el.Attr { |
| + a[v.Name.Local] = v.Value |
| + } |
| + if el.Name.Local == "meta" && strings.EqualFold(a["http-equiv"], "refresh") { |
| + refresh = append(refresh, a["content"]) |
| + } |
| + if el.Name.Local == "a" { |
| + if a["referrerpolicy"] != "no-referrer" { |
| + t.Fatal("anchor sends referrer") |
| + } |
| + links = append(links, a["href"]) |
| + } |
| + } |
| + return |
| +} |
| +func assertDocsContinuation(t *testing.T, r *httptest.ResponseRecorder, want string) { |
| + t.Helper() |
| + noStore(t, r) |
| + assertContinuationPage(t, r, want) |
| +} |
| +func assertContinuationPage(t *testing.T, r *httptest.ResponseRecorder, want string) { |
| + t.Helper() |
| + if r.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("cacheable continuation") |
| + } |
| + if r.Code != 200 || r.Header().Get("Location") != "" || r.Header().Get("Refresh") != "" || r.Header().Get("Content-Type") != "text/html; charset=utf-8" || r.Header().Get("Referrer-Policy") != "no-referrer" { |
| + t.Fatal(r.Code, r.Header()) |
| + } |
| + refresh, links := continuationTargets(t, r.Body.String()) |
| + if len(refresh) != 1 || refresh[0] != "0;url="+want || len(links) != 1 || links[0] != want || strings.Contains(r.Body.String(), "ZgotmplZ") || strings.Contains(r.Body.String(), "<script") { |
| + t.Fatal(refresh, links, r.Body.String()) |
| + } |
| +} |
| +func TestDocsContinueCanonicalResponseBoundary(t *testing.T) { |
| + f := newChooserFixture(t) |
| + c, err := f.p.directory.newDocsChooser(f.store) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} { |
| + r := httptest.NewRecorder() |
| + c.continueToDocs(r, directorydata.Choice{Origin: origin}) |
| + assertDocsContinuation(t, r, origin+"/auth/start?return_to=%2F") |
| + } |
| + for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} { |
| + r := httptest.NewRecorder() |
| + c.continueToDocs(r, directorydata.Choice{Origin: origin}) |
| + if r.Code != 503 || strings.Contains(r.Body.String(), "http-equiv") || r.Header().Get("Location") != "" { |
| + t.Fatal(origin, r.Code, r.Body.String()) |
| + } |
| + } |
| +} |
| +func TestDocsContinueRendererEscapesIdenticalTargets(t *testing.T) { |
| + f := newChooserFixture(t) |
| + // Exercise real template contexts independently of the fixed runtime URL. |
| + want := "https://alpha.docs.oficina.works/auth/start?return_to=%2F&probe=%22%3C" |
| + r := httptest.NewRecorder() |
| + f.p.directory.render(r, 200, "docs_continue.html", struct{ Destination string }{want}) |
| + refresh, links := continuationTargets(t, r.Body.String()) |
| + if len(refresh) != 1 || refresh[0] != "0;url="+want || len(links) != 1 || links[0] != want || !strings.Contains(r.Body.String(), "&probe=") { |
| + t.Fatal(refresh, links, r.Body.String()) |
| + } |
| + for name, page := range f.p.directory.renderer.pages { |
| + if name == "docs_continue.html" { |
| + continue |
| + } |
| + var b bytes.Buffer |
| + _ = page.ExecuteTemplate(&b, "layout", map[string]any{}) |
| + if strings.Contains(strings.ToLower(b.String()), `http-equiv="refresh"`) { |
| + t.Fatalf("refresh leaked to %s", name) |
| + } |
| + } |
| +} |
| +func TestDocsContinueRequeriesCurrentOrigin(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + id := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + f.team(t, "B2", "beta", "Beta", chooserPerson) |
| + if r := f.request("GET", docsEntryPath, "", "", cookie); r.Code != 200 { |
| + t.Fatal(r.Code) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE teams SET label='changed' WHERE id=?`, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://changed.docs.oficina.works' WHERE team_id=?`, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/auth/start?return_to=%2F") |
| +} |
| +func TestDocsContinueExactHostAndExpiredSession(t *testing.T) { |
| + f := newChooserFixture(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + for _, method := range []string{"GET", "POST"} { |
| + path := docsEntryPath |
| + if method == "POST" { |
| + path = docsChoicePath |
| + } |
| + r := httptest.NewRequest(method, directoryOrigin+path, strings.NewReader("team=A1")) |
| + r.Host = "wrong.example" |
| + r.AddCookie(cookie) |
| + r.Header.Set("Origin", directoryOrigin) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + if w.Code != 404 { |
| + t.Fatal(method, w.Code) |
| + } |
| + } |
| + if _, err := f.p.directory.db.Writer().ExecContext(context.Background(), `UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.request(http.MethodPost, docsChoicePath, directoryOrigin, "team=A1", cookie) |
| + assertChooserSigninContinuation(t, r, cookie.Name) |
| +} |
diff --git a/internal/home/runtime.go b/internal/home/runtime.go| index d8f7b30..db6c6a0 100644 |
| --- a/internal/home/runtime.go |
| +++ b/internal/home/runtime.go |
| @@ -10,6 +10,7 @@ import ( |
| "os" |
| "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| "amadan.net/rastrillo/rastrillo/assertion" |
| assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -19,7 +20,7 @@ import ( |
| ) |
| var errExchangeConfig = errors.New("home: invalid assertion configuration") |
| -var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff")) |
| +var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), directorydata.Schema) |
| type Runtime struct { |
| role string |
diff --git a/internal/home/templates/docs_chooser.html b/internal/home/templates/docs_chooser.html| new file mode 100644 |
| index 0000000..5447580 |
| --- /dev/null |
| +++ b/internal/home/templates/docs_chooser.html |
| @@ -0,0 +1,11 @@ |
| +{{define "title"}}Choose a team · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>Choose a team</h1> |
| +<p>Open Docs for one of your teams.</p> |
| +<form method="post" action="/apps/docs/team" rst-form> |
| +{{range .Choices}} |
| +<button type="submit" name="team" value="{{.SQID}}" rst-btn>{{.Name}}</button> |
| +{{end}} |
| +</form> |
| +<p><a href="/">Back to Home</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/docs_continue.html b/internal/home/templates/docs_continue.html| new file mode 100644 |
| index 0000000..ff03281 |
| --- /dev/null |
| +++ b/internal/home/templates/docs_continue.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}Continue to Docs{{end}} |
| +{{define "head"}}<meta http-equiv="refresh" content="0;url={{.Destination}}">{{end}} |
| +{{define "content"}} |
| +<h1>Continue to Docs</h1> |
| +<p><a href="{{.Destination}}" referrerpolicy="no-referrer">Continue to Docs</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/docs_empty.html b/internal/home/templates/docs_empty.html| new file mode 100644 |
| index 0000000..4525085 |
| --- /dev/null |
| +++ b/internal/home/templates/docs_empty.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}No teams available · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>No teams available</h1> |
| +<p>This account has no team with Docs available.</p> |
| +<p><a href="/">Back to Home</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/docs_unavailable.html b/internal/home/templates/docs_unavailable.html| new file mode 100644 |
| index 0000000..51e2bcd |
| --- /dev/null |
| +++ b/internal/home/templates/docs_unavailable.html |
| @@ -0,0 +1,6 @@ |
| +{{define "title"}}Could not load your teams · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>Could not load your teams</h1> |
| +<p>Try again in a moment.</p> |
| +<p><a href="/">Back to Home</a></p> |
| +{{end}} |
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html| index 9340be0..6054667 100644 |
| --- a/internal/home/templates/layout.html |
| +++ b/internal/home/templates/layout.html |
| @@ -6,6 +6,7 @@ |
| <title>{{block "title" .}}Oficina{{end}}</title> |
| <link rel="stylesheet" href="/static/tokens.css"> |
| <link rel="stylesheet" href="/static/theme.css"> |
| +{{block "head" .}}{{end}} |
| </head> |
| <body{{block "bodyattrs" .}}{{end}}> |
| <main rst-page> |