oficina / home Public

Mount directory-owned Docs choices with canonical team routes

Paul Campbell pushed by paul@keymail.dev f55185d56e7d1a19c5891f7971c724622f1b21c9 parent 69f8897
6 files changed, +233 −7
  • internal/home/app.go +3 −2
  • internal/home/directory.go +9 −1
  • internal/home/directory_runtime.go +84 −0
  • internal/home/directory_runtime_test.go +132 −0
  • internal/home/docs_chooser_test.go +3 −3
  • internal/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