Mount directory-owned Docs choices with canonical team routes
6 files changed,
+233
−7
internal/home/app.go+3 −2internal/home/directory.go+9 −1internal/home/directory_runtime.go+84 −0internal/home/directory_runtime_test.go+132 −0internal/home/docs_chooser_test.go+3 −3internal/home/runtime.go+2 −1
CI passed — run details
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..ec59f4a |
| --- /dev/null |
| +++ b/internal/home/directory_runtime_test.go |
| @@ -0,0 +1,132 @@ |
| +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") |
| + } |
| +} |
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go| index eb76b4a..98b9b40 100644 |
| --- a/internal/home/docs_chooser_test.go |
| +++ b/internal/home/docs_chooser_test.go |
| @@ -234,7 +234,7 @@ func TestDocsChooserRefusesHostileInputAndCrossOriginSelection(t *testing.T) { |
| t.Fatal(r.Code) |
| } |
| } |
| -func TestDocsChooserFailureIsPrivateAndUnmounted(t *testing.T) { |
| +func TestDocsChooserFailureIsPrivateWhenMounted(t *testing.T) { |
| f := newChooserFixture(t) |
| cookie := f.session(t, chooserPerson) |
| f.team(t, "A1", "alpha", "Private team name", chooserPerson) |
| @@ -247,8 +247,8 @@ func TestDocsChooserFailureIsPrivateAndUnmounted(t *testing.T) { |
| t.Fatal(r.Code, r.Body.String()) |
| } |
| r = exchangeRequest(f.p.directory, "GET", directoryOrigin+docsEntryPath, "", nil, cookie) |
| - if r.Code != 404 { |
| - t.Fatal("chooser mounted outside test", r.Code) |
| + if r.Code != 503 { |
| + t.Fatal("mounted chooser did not preserve storage refusal", r.Code) |
| } |
| } |
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 |