| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "io" |
| 12 | "log/slog" |
| 13 | "net/http" |
| 14 | "net/url" |
| 15 | "path/filepath" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "amadan.net/oficina/home/roster" |
| 22 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 23 | "amadan.net/rastrillo/rastrillo/sessions" |
| 24 | "github.com/carlosframework/docs/internal/docs/principalstore" |
| 25 | ) |
| 26 | |
| 27 | type teamRouterDirectory struct { |
| 28 | mu sync.Mutex |
| 29 | key *family.Keypair |
| 30 | bindings roster.Bindings |
| 31 | generation string |
| 32 | epoch int64 |
| 33 | members []roster.MemberEntry |
| 34 | fail bool |
| 35 | } |
| 36 | |
| 37 | func (d *teamRouterDirectory) RoundTrip(r *http.Request) (*http.Response, error) { |
| 38 | d.mu.Lock() |
| 39 | defer d.mu.Unlock() |
| 40 | if d.fail { |
| 41 | return nil, errors.New("directory unavailable") |
| 42 | } |
| 43 | q := r.URL.Query() |
| 44 | if r.URL.Scheme+"://"+r.URL.Host != d.bindings.Directory || r.URL.Path != "/roster" || q.Get("team") != d.bindings.Team || !roster.ValidChallenge(q.Get("fetch")) || q.Get("cursor") != "" || r.Header.Get("Authorization") != "Bearer "+base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) { |
| 45 | return nil, errors.New("wrong roster request scope") |
| 46 | } |
| 47 | index, complete := 0, true |
| 48 | members := append([]roster.MemberEntry{}, d.members...) |
| 49 | p := roster.Page{Version: 1, Type: "page", Directory: d.bindings.Directory, Consumer: d.bindings.Consumer, Team: d.bindings.Team, Audience: d.bindings.Audience, Fetch: q.Get("fetch"), RequestCursorHash: roster.CursorHash(""), Generation: d.generation, Epoch: d.epoch, Index: &index, Members: &members, Complete: &complete} |
| 50 | raw, e := roster.SignPage(d.key, "roster1", p) |
| 51 | if e != nil { |
| 52 | return nil, e |
| 53 | } |
| 54 | return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil |
| 55 | } |
| 56 | func (d *teamRouterDirectory) change(members []roster.MemberEntry, fail bool) { |
| 57 | d.mu.Lock() |
| 58 | defer d.mu.Unlock() |
| 59 | d.members = members |
| 60 | d.epoch++ |
| 61 | d.fail = fail |
| 62 | } |
| 63 | |
| 64 | type teamRouterFixture struct { |
| 65 | *routerHomeFixture |
| 66 | directory *teamRouterDirectory |
| 67 | clock *teamTestClock |
| 68 | cfg Config |
| 69 | alice, bob int64 |
| 70 | own, shared, hidden *Document |
| 71 | } |
| 72 | |
| 73 | func newTeamRouterFixture(t *testing.T) *teamRouterFixture { |
| 74 | t.Helper() |
| 75 | ctx := t.Context() |
| 76 | credential, e := family.Generate() |
| 77 | if e != nil { |
| 78 | t.Fatal(e) |
| 79 | } |
| 80 | digest := sha256.Sum256(credential.SignPub()) |
| 81 | kid := hex.EncodeToString(digest[:]) |
| 82 | manifest, _ := json.Marshal(map[string]string{kid: base64.StdEncoding.EncodeToString(credential.SignPub())}) |
| 83 | home := HomeConfig{Issuer: bridgeIssuer, ConsumerID: "router-docs", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{83}, 32))} |
| 84 | cfg := Config{DBPath: filepath.Join(t.TempDir(), "team.db"), Env: "development", Origin: bridgeOrigin, Home: &home, AllowedAddresses: "alice@example.com,bob@example.com", SampleDocuments: "0", Mailer: navigationMail{}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| 85 | app, e := New(cfg) |
| 86 | if e != nil { |
| 87 | t.Fatal(e) |
| 88 | } |
| 89 | f := &teamRouterFixture{cfg: cfg, clock: &teamTestClock{}} |
| 90 | f.alice, e = app.store.EnsurePerson(ctx, "alice@example.com") |
| 91 | if e != nil { |
| 92 | t.Fatal(e) |
| 93 | } |
| 94 | f.bob, e = app.store.EnsurePerson(ctx, "bob@example.com") |
| 95 | if e != nil { |
| 96 | t.Fatal(e) |
| 97 | } |
| 98 | f.own, e = app.store.Create(ctx, f.alice, "Alice original") |
| 99 | if e != nil { |
| 100 | t.Fatal(e) |
| 101 | } |
| 102 | f.shared, e = app.store.Create(ctx, f.bob, "Bob shared") |
| 103 | if e != nil { |
| 104 | t.Fatal(e) |
| 105 | } |
| 106 | f.hidden, e = app.store.Create(ctx, f.bob, "Bob private") |
| 107 | if e != nil { |
| 108 | t.Fatal(e) |
| 109 | } |
| 110 | if e = app.store.Grant(ctx, f.bob, f.shared.Sqid, "alice@example.com", RoleWriter); e != nil { |
| 111 | t.Fatal(e) |
| 112 | } |
| 113 | for _, d := range []*Document{f.own, f.shared, f.hidden} { |
| 114 | address := "alice@example.com" |
| 115 | if d.OwnerID == f.bob { |
| 116 | address = "bob@example.com" |
| 117 | } |
| 118 | if _, e = app.store.ApplyUpdate(ctx, d, localActor(d.OwnerID, address), validUpdate(t)); e != nil { |
| 119 | t.Fatal(e) |
| 120 | } |
| 121 | } |
| 122 | if e = app.Close(); e != nil { |
| 123 | t.Fatal(e) |
| 124 | } |
| 125 | operator, e := family.Generate() |
| 126 | if e != nil { |
| 127 | t.Fatal(e) |
| 128 | } |
| 129 | signer, e := family.Generate() |
| 130 | if e != nil { |
| 131 | t.Fatal(e) |
| 132 | } |
| 133 | fixed := func(v byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{v}, 32)) } |
| 134 | binding := roster.Bindings{Issuer: bridgeIssuer, Directory: "https://home.example", Consumer: "router-docs", App: "docs", Team: "TeamA", Audience: bridgeOrigin} |
| 135 | directory := &teamRouterDirectory{key: signer, bindings: binding, generation: fixed(1), epoch: 1, members: []roster.MemberEntry{{Subject: teamSubject('a'), Role: "member"}, {Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}} |
| 136 | record := roster.Provisioning{Version: 1, Operation: "team-provision", Sequence: 1, Bindings: binding, Generation: directory.generation, MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(signer.SignPub())}}} |
| 137 | wire, e := roster.SignProvisioning(operator, "operator1", record) |
| 138 | if e != nil { |
| 139 | t.Fatal(e) |
| 140 | } |
| 141 | keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(operator.SignPub())}}) |
| 142 | cfg.Team = &TeamConfig{TeamID: binding.Team, DirectoryOrigin: binding.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(wire), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: fixed(7), clock: f.clock, transport: directory} |
| 143 | app, e = New(cfg) |
| 144 | if e != nil { |
| 145 | t.Fatal(e) |
| 146 | } |
| 147 | t.Cleanup(func() { |
| 148 | if e := app.Close(); e != nil { |
| 149 | t.Error(e) |
| 150 | } |
| 151 | }) |
| 152 | sessionStore, e := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: bridgeOrigin}) |
| 153 | if e != nil { |
| 154 | t.Fatal(e) |
| 155 | } |
| 156 | f.routerHomeFixture = &routerHomeFixture{app: app, key: credential, keyID: kid, sessions: sessionStore} |
| 157 | f.directory = directory |
| 158 | f.cfg = cfg |
| 159 | return f |
| 160 | } |
| 161 | func teamSubject(c byte) string { return "pr_" + strings.Repeat(string(c), 26) } |
| 162 | func (b *routerBrowser) update(doc *Document, body []byte) int { |
| 163 | r := b.request("POST", "/d/"+doc.Sqid+"/updates", string(body)) |
| 164 | r.Header.Set("Content-Type", "application/octet-stream") |
| 165 | return b.serve(r).Code |
| 166 | } |
| 167 | func TestTeamRouterLocalLinkMembershipRemovalAndIndependentGrant(t *testing.T) { |
| 168 | f := newTeamRouterFixture(t) |
| 169 | b := f.browser() |
| 170 | b.localSignin(t, "alice@example.com") |
| 171 | if r := b.do("GET", "/d/"+f.own.Sqid, ""); r.Code != 404 { |
| 172 | t.Fatal("email admission became team standing", r.Code) |
| 173 | } |
| 174 | if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 { |
| 175 | t.Fatal("unlinked guest lost exact grant", r.Code) |
| 176 | } |
| 177 | cookie := b.cookies[f.sessions.CookieName()].Value |
| 178 | b.linkProof(t, teamSubject('a'), "alice@example.com") |
| 179 | routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/") |
| 180 | if b.cookies[f.sessions.CookieName()].Value != cookie { |
| 181 | t.Fatal("link rotated local session") |
| 182 | } |
| 183 | for _, d := range []*Document{f.own, f.shared} { |
| 184 | if r := b.do("GET", "/d/"+d.Sqid, ""); r.Code != 200 { |
| 185 | t.Fatal("linked member lost access", r.Code) |
| 186 | } |
| 187 | } |
| 188 | if r := b.do("GET", "/d/"+f.hidden.Sqid, ""); r.Code != 404 { |
| 189 | t.Fatal("membership opened another owner private document", r.Code) |
| 190 | } |
| 191 | if r := b.do("GET", "/d/"+f.shared.Sqid+"/share", ""); r.Code != 404 { |
| 192 | t.Fatal("writer grant became owner administration", r.Code) |
| 193 | } |
| 194 | if r := b.do("GET", "/d/"+f.own.Sqid+"/share", ""); r.Code != 200 { |
| 195 | t.Fatal("member owner cannot administer", r.Code) |
| 196 | } |
| 197 | f.clock.step(roster.RetryAfter) |
| 198 | f.directory.change([]roster.MemberEntry{{Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}, false) |
| 199 | if e := f.app.store.team.cache.Refresh(t.Context()); e != nil { |
| 200 | t.Fatal(e) |
| 201 | } |
| 202 | for _, path := range []string{"/d/" + f.own.Sqid, "/d/nonexistent", "/d/" + f.own.Sqid + "/share"} { |
| 203 | if r := b.do("GET", path, ""); r.Code != 404 { |
| 204 | t.Fatal("removed owner retained item access", path, r.Code) |
| 205 | } |
| 206 | } |
| 207 | if r := b.do("POST", "/documents", url.Values{"title": {"refused"}}.Encode()); r.Code != 403 { |
| 208 | t.Fatal("removed member created document", r.Code) |
| 209 | } |
| 210 | if code := b.update(f.own, validUpdate(t)); code != 404 { |
| 211 | t.Fatal("removed owner update not definitely refused", code) |
| 212 | } |
| 213 | if code := b.update(f.shared, validUpdate(t)); code != 204 { |
| 214 | t.Fatal("independent writer grant lost after membership removal", code) |
| 215 | } |
| 216 | f.directory.change(nil, true) |
| 217 | f.clock.step(60 * time.Second) |
| 218 | if e := f.app.store.team.cache.Refresh(t.Context()); e == nil { |
| 219 | t.Fatal("directory failure accepted") |
| 220 | } |
| 221 | for _, path := range []string{"/d/" + f.own.Sqid + "/share", "/d/nonexistent/share"} { |
| 222 | if r := b.do("GET", path, ""); r.Code != 503 { |
| 223 | t.Fatal("unknown owner administration became permanent or leaked existence", r.Code) |
| 224 | } |
| 225 | } |
| 226 | missing := b.do("GET", "/d/nonexistent", "") |
| 227 | private := b.do("GET", "/d/"+f.own.Sqid, "") |
| 228 | if missing.Code != 503 || private.Code != 503 || missing.Body.String() != private.Body.String() { |
| 229 | t.Fatal("unknown standing leaked existence or became permanent refusal") |
| 230 | } |
| 231 | if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 { |
| 232 | t.Fatal("grant unavailable during roster failure", r.Code) |
| 233 | } |
| 234 | if r := b.do("GET", "/", ""); r.Code != 200 || !strings.Contains(r.Body.String(), `rst-tone="warning"`) || !strings.Contains(r.Body.String(), `<p>docs.team.partial</p>`) || strings.Contains(r.Body.String(), "Alice original") { |
| 235 | t.Fatal("partial list misrepresented unavailable owned documents", r.Code) |
| 236 | } |
| 237 | if r := b.do("GET", "/documents/search?q=shared", ""); r.Code != 200 || r.Header().Get("Docs-Catalog-Partial") != "1" { |
| 238 | t.Fatal("partial search lost status", r.Code) |
| 239 | } |
| 240 | if code := b.update(f.own, validUpdate(t)); code != 503 { |
| 241 | t.Fatal("uncertain update became permanent failure", code) |
| 242 | } |
| 243 | if code := b.update(f.shared, validUpdate(t)); code != 204 { |
| 244 | t.Fatal("writer grant depended on roster availability", code) |
| 245 | } |
| 246 | signoutChained(t, b.do("POST", "/signout", "")) |
| 247 | nonce := b.start(t, "/auth/start?return_to=%2F", "") |
| 248 | authTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) |
| 249 | routerRedirect(t, b.exchange(t, nonce, teamSubject('a'), authTime), "/") |
| 250 | session, ok := f.sessions.From(b.request("GET", "/", "")) |
| 251 | if !ok || session.Method != homeAssertionMethod || !session.AuthTime.Equal(authTime) { |
| 252 | t.Fatal("ordinary Home sign-in changed authentication time") |
| 253 | } |
| 254 | if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 { |
| 255 | t.Fatal("linked Home identity lost current verified grant address", r.Code) |
| 256 | } |
| 257 | p, e := f.app.store.team.principals.Resolve(context.Background(), bridgeIssuer, teamSubject('a')) |
| 258 | if e != nil || p.ID != f.alice || p.Kind != principalstore.Local { |
| 259 | t.Fatal("existing local ownership identity changed", e) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | func TestTeamRouterHomeOnlyFirstEntryAndConflict(t *testing.T) { |
| 264 | f := newTeamRouterFixture(t) |
| 265 | b := f.browser() |
| 266 | nonce := b.start(t, "/auth/start?return_to=%2F", "") |
| 267 | routerRedirect(t, b.exchange(t, nonce, teamSubject('b'), time.Now().Add(-time.Hour)), "/") |
| 268 | p, e := f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('b')) |
| 269 | if e != nil || p.Kind != principalstore.Home || p.VerifiedAddress != nil { |
| 270 | t.Fatal("first Home entry did not create addressless principal", e) |
| 271 | } |
| 272 | var count int64 |
| 273 | if e = f.app.store.db.Model(&Person{}).Count(&count).Error; e != nil || count != 2 { |
| 274 | t.Fatal("Home entry created legacy email person", count, e) |
| 275 | } |
| 276 | if r := b.do("GET", "/", ""); r.Code != 200 || strings.Contains(r.Body.String(), "Alice original") || strings.Contains(r.Body.String(), "Bob private") { |
| 277 | t.Fatal("new Home principal inherited local ownership", r.Code) |
| 278 | } |
| 279 | r := b.do("POST", "/documents", url.Values{"title": {"Home document"}}.Encode()) |
| 280 | target := routerRedirect(t, r, "") |
| 281 | if !strings.HasPrefix(target, "/d/") { |
| 282 | t.Fatal("member could not create document") |
| 283 | } |
| 284 | var d Document |
| 285 | if e = f.app.store.db.Where("sqid = ?", strings.TrimPrefix(target, "/d/")).First(&d).Error; e != nil || d.OwnerID != p.ID { |
| 286 | t.Fatal("Home-created document has wrong owner", e) |
| 287 | } |
| 288 | nonce = b.start(t, "/auth/start?return_to=%2F", "") |
| 289 | if r = b.exchange(t, nonce, teamSubject('c'), time.Now()); r.Code != 400 { |
| 290 | t.Fatal("existing browser switched principal", r.Code) |
| 291 | } |
| 292 | if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('c')); !errors.Is(e, principalstore.ErrRefused) { |
| 293 | t.Fatal("conflict allocated unknown principal", e) |
| 294 | } |
| 295 | if r = b.do("GET", target, ""); r.Code != 200 { |
| 296 | t.Fatal("conflict replaced current session", r.Code) |
| 297 | } |
| 298 | outsider := f.browser() |
| 299 | nonce = outsider.start(t, "/auth/start?return_to=%2F", "") |
| 300 | if r = outsider.exchange(t, nonce, teamSubject('z'), time.Now()); r.Code != 403 { |
| 301 | t.Fatal("nonmember first entry accepted", r.Code) |
| 302 | } |
| 303 | if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('z')); !errors.Is(e, principalstore.ErrRefused) { |
| 304 | t.Fatal("nonmember allocated principal", e) |
| 305 | } |
| 306 | if r = b.do("GET", "/account/home", ""); r.Code != 303 || r.Header().Get("Location") != "/signin/local" { |
| 307 | t.Fatal("addressless account cannot begin explicit local reauthentication", r.Code, r.Header().Get("Location")) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | func TestTeamRouterActivatedDatabaseRefusesMissingAndMismatchedConfiguration(t *testing.T) { |
| 312 | f := newTeamRouterFixture(t) |
| 313 | cfg := f.cfg |
| 314 | cfg.Team = nil |
| 315 | if app, e := New(cfg); e == nil { |
| 316 | app.Close() |
| 317 | t.Fatal("activated database reopened without TeamMode") |
| 318 | } |
| 319 | mismatch := *f.cfg.Team |
| 320 | mismatch.TeamID = "TeamB" |
| 321 | cfg.Team = &mismatch |
| 322 | if app, e := New(cfg); e == nil { |
| 323 | app.Close() |
| 324 | t.Fatal("activated database accepted foreign team configuration") |
| 325 | } |
| 326 | cfg = f.cfg |
| 327 | cfg.Home = nil |
| 328 | if app, e := New(cfg); e == nil { |
| 329 | app.Close() |
| 330 | t.Fatal("activated database accepted missing Home identity configuration") |
| 331 | } |
| 332 | } |
| 333 | |