Add unmounted TeamMode identity and durable authority state
22 files changed,
+559
−42
go.mod+4 −3go.sum+2 −2internal/docs/auth.go+4 −1internal/docs/content_runtime_test.go+2 −2internal/docs/grants.go+35 −11internal/docs/grants_test.go+6 −6internal/docs/grants_write_test.go+5 −5internal/docs/handlers.go+1 −1internal/docs/home_exchange_test.go+1 −1internal/docs/home_router_integration_test.go+1 −1internal/docs/identity.go+36 −4internal/docs/identity_test.go+2 −2internal/docs/principalstore/testdata/compat_main.go.txt+1 −1internal/docs/principalstore/testdata/compat_new.go.txt+5 −0internal/docs/principalstore/testdata/compat_old.go.txt+5 −0internal/docs/store.go+2 −1internal/docs/team_config.go+116 −0internal/docs/team_identity_test.go+133 −0internal/docs/teamstate/migrations/0001_team.sql+10 −0internal/docs/teamstate/store.go+98 −0internal/docs/teamstate/store_test.go+89 −0internal/docs/testing.go+1 −1
CI failed — run details
diff --git a/go.mod b/go.mod| index 2b025da..7d75590 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,18 +5,20 @@ go 1.26.5 |
| require ( |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| github.com/google/uuid v1.6.0 |
| github.com/tetratelabs/wazero v1.12.0 |
| golang.org/x/text v0.20.0 |
| gorm.io/gorm v1.31.2 |
| + modernc.org/sqlite v1.55.0 |
| ) |
| require ( |
| - amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| github.com/keymaildev/signin v0.1.1 // indirect |
| @@ -28,5 +30,4 @@ require ( |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
| - modernc.org/sqlite v1.55.0 // indirect |
| ) |
diff --git a/go.sum b/go.sum| index 69457d9..b43fed0 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,9 +1,9 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg= |
| amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| -amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA= |
| -amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
| +amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
diff --git a/internal/docs/auth.go b/internal/docs/auth.go| index 376dc04..111afb5 100644 |
| --- a/internal/docs/auth.go |
| +++ b/internal/docs/auth.go |
| @@ -394,7 +394,10 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.Ha |
| func requireAdmission(list allowlist, signinPath string) func(http.Handler) http.Handler { |
| admission := requireAdmissionOf(list, signinPath, func(r *http.Request) (string, bool) { |
| if id, ok := currentIdentity(r); ok { |
| - return id.Actor.Subject, true |
| + if id.Actor.VerifiedAddress == nil { |
| + return "", false |
| + } |
| + return *id.Actor.VerifiedAddress, true |
| } |
| s, ok := sessions.Current(r) |
| return s.Subject, ok && s.Method != homeAssertionMethod |
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go| index 17b081a..66d09c5 100644 |
| --- a/internal/docs/content_runtime_test.go |
| +++ b/internal/docs/content_runtime_test.go |
| @@ -133,7 +133,7 @@ func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) { |
| if err := convertContent(ctx, app.store.db, cipher); err != nil { |
| t.Fatal(err) |
| } |
| - if seq, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| + if seq, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err) |
| } |
| update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| @@ -287,7 +287,7 @@ func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); err != nil { |
| + if _, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); err != nil { |
| t.Fatal(err) |
| } |
| h, seq, _, err := app.store.replay(ctx, doc) |
diff --git a/internal/docs/grants.go b/internal/docs/grants.go| index 5574a77..286dae4 100644 |
| --- a/internal/docs/grants.go |
| +++ b/internal/docs/grants.go |
| @@ -67,8 +67,17 @@ var errEmptyAddress = errors.New("docs: cannot grant to an empty address") |
| // keyed on the id and grants are keyed on the address — see 0005_grants.sql |
| // for why grants are not keyed on the id. |
| type Actor struct { |
| - PersonID int64 |
| - Subject string |
| + PersonID int64 |
| + VerifiedAddress *string |
| +} |
| + |
| +// localActor keeps a verified local address distinct from an opaque Home subject. |
| +func localActor(id int64, address string) Actor { |
| + address = subjectOf(address) |
| + if address == "" { |
| + return Actor{PersonID: id} |
| + } |
| + return Actor{PersonID: id, VerifiedAddress: &address} |
| } |
| // writable scopes a query to the documents one person may change: the ones |
| @@ -79,11 +88,14 @@ func writable(g *gorm.DB, a Actor) *gorm.DB { |
| if a.PersonID <= 0 { |
| return g.Where("1 = 0") |
| } |
| + if a.VerifiedAddress == nil { |
| + return g.Where("documents.owner_id > 0 AND documents.owner_id = ?", a.PersonID) |
| + } |
| return g.Where( |
| "documents.owner_id > 0 AND (documents.owner_id = ? OR EXISTS ("+ |
| "SELECT 1 FROM grants WHERE grants.doc_id = documents.id "+ |
| "AND grants.subject = ? AND grants.role = ?))", |
| - a.PersonID, subjectOf(a.Subject), RoleWriter, |
| + a.PersonID, subjectOf(*a.VerifiedAddress), RoleWriter, |
| ) |
| } |
| @@ -100,12 +112,20 @@ func subjectOf(address string) string { |
| // addressOf maps a person id back to the address they signed in with, so |
| // Grant can refuse a grant somebody makes to themselves. |
| -func (s *Store) addressOf(ctx context.Context, person int64) (string, error) { |
| +func (s *Store) addressOf(ctx context.Context, person int64) (*string, error) { |
| + if s.team != nil { |
| + p, err := s.team.principals.Person(ctx, person) |
| + return p.VerifiedAddress, err |
| + } |
| var p Person |
| if err := s.db.WithContext(ctx).First(&p, person).Error; err != nil { |
| - return "", err |
| + return nil, err |
| } |
| - return subjectOf(p.Email), nil |
| + address := subjectOf(p.Email) |
| + if address == "" { |
| + return nil, errIdentityRefused |
| + } |
| + return &address, nil |
| } |
| // looksLikeAnAddress is the cheapest check that keeps nonsense out of the |
| @@ -139,14 +159,18 @@ func looksLikeAnAddress(subject string) bool { |
| // it. 0002_owners.sql wrote owner_id 0 onto every pre-existing row, and three |
| // tests pin that 0 matches nothing; a grants clause must not become the way |
| // around them. |
| -func readable(g *gorm.DB, owner int64, subject string) *gorm.DB { |
| +func readable(g *gorm.DB, a Actor) *gorm.DB { |
| + owner := a.PersonID |
| if owner <= 0 { |
| return g.Where("1 = 0") |
| } |
| + if a.VerifiedAddress == nil { |
| + return g.Where("documents.owner_id > 0 AND documents.owner_id = ?", a.PersonID) |
| + } |
| return g.Where( |
| "documents.owner_id > 0 AND (documents.owner_id = ? OR EXISTS ("+ |
| "SELECT 1 FROM grants WHERE grants.doc_id = documents.id AND grants.subject = ?))", |
| - owner, subjectOf(subject), |
| + owner, subjectOf(*a.VerifiedAddress), |
| ) |
| } |
| @@ -181,7 +205,7 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro |
| if err != nil { |
| return fmt.Errorf("docs: resolve owner address: %w", err) |
| } |
| - if ownerAddress == subject { |
| + if ownerAddress != nil && *ownerAddress == subject { |
| return errEmptyAddress |
| } |
| @@ -283,7 +307,7 @@ func (s *Store) Revoke(ctx context.Context, owner int64, sqid, address string) e |
| // as one that does not exist, and the handler turns both into 404 (§4.2). |
| func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, error) { |
| var doc Document |
| - err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject). |
| + err := readable(s.db.WithContext(ctx), a). |
| Where("documents.sqid = ? AND documents.deleted_at IS NULL", sqid). |
| First(&doc).Error |
| if err != nil { |
| @@ -330,7 +354,7 @@ func (s *Store) CanWrite(ctx context.Context, a Actor, docID int64) bool { |
| // docRow.SharedNote, without reordering the list. |
| func (s *Store) ListFor(ctx context.Context, a Actor) ([]*Document, error) { |
| var docs []*Document |
| - err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject). |
| + err := readable(s.db.WithContext(ctx), a). |
| Where("documents.deleted_at IS NULL"). |
| Order("documents.updated_at DESC, documents.id DESC"). |
| Find(&docs).Error |
diff --git a/internal/docs/grants_test.go b/internal/docs/grants_test.go| index 09e817b..c4f9b33 100644 |
| --- a/internal/docs/grants_test.go |
| +++ b/internal/docs/grants_test.go |
| @@ -45,7 +45,7 @@ func TestReadableIncludesGrantees(t *testing.T) { |
| // Bob reads it because he was granted it. |
| var bobRows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&bobRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&bobRows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(bobRows) != 1 || bobRows[0].ID != doc.ID { |
| @@ -54,7 +54,7 @@ func TestReadableIncludesGrantees(t *testing.T) { |
| // Alice still reads her own. |
| var aliceRows []*Document |
| - if err := readable(store.db.WithContext(ctx), alice, "alice@example.com").Find(&aliceRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(alice, "alice@example.com")).Find(&aliceRows).Error; err != nil { |
| t.Fatalf("readable(alice): %v", err) |
| } |
| if len(aliceRows) != 1 { |
| @@ -64,7 +64,7 @@ func TestReadableIncludesGrantees(t *testing.T) { |
| // Carol was granted nothing and reads nothing. Without this the whole |
| // thing could be a query that returns every row. |
| var carolRows []*Document |
| - if err := readable(store.db.WithContext(ctx), carol, "carol@example.com").Find(&carolRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(carol, "carol@example.com")).Find(&carolRows).Error; err != nil { |
| t.Fatalf("readable(carol): %v", err) |
| } |
| if len(carolRows) != 0 { |
| @@ -111,7 +111,7 @@ func TestGrantToSomebodyWhoHasNeverSignedIn(t *testing.T) { |
| t.Fatalf("EnsurePerson(dave): %v", err) |
| } |
| var daveRows []*Document |
| - if err := readable(store.db.WithContext(ctx), dave, "dave@example.com").Find(&daveRows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(dave, "dave@example.com")).Find(&daveRows).Error; err != nil { |
| t.Fatalf("readable(dave): %v", err) |
| } |
| if len(daveRows) != 1 { |
| @@ -141,7 +141,7 @@ func TestGrantSubjectIsNormalised(t *testing.T) { |
| } |
| var rows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(rows) != 1 { |
| @@ -178,7 +178,7 @@ func TestReadableKeepsOrphansInvisible(t *testing.T) { |
| } |
| var rows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(rows) != 0 { |
diff --git a/internal/docs/grants_write_test.go b/internal/docs/grants_write_test.go| index 579636e..fab13b3 100644 |
| --- a/internal/docs/grants_write_test.go |
| +++ b/internal/docs/grants_write_test.go |
| @@ -40,7 +40,7 @@ func TestApplyUpdateRefusesAReader(t *testing.T) { |
| // Bob can read it — that is what the grant is for. |
| var rows []*Document |
| - if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil { |
| + if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil { |
| t.Fatalf("readable(bob): %v", err) |
| } |
| if len(rows) != 1 { |
| @@ -48,7 +48,7 @@ func TestApplyUpdateRefusesAReader(t *testing.T) { |
| } |
| // And he cannot write it. |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: bob, Subject: "bob@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(bob, "bob@example.com"), []byte{0}) |
| if !errors.Is(err, ErrNotWritable) { |
| t.Errorf("ApplyUpdate as a reader: got %v, want ErrNotWritable", err) |
| } |
| @@ -77,7 +77,7 @@ func TestApplyUpdateAllowsAWriter(t *testing.T) { |
| // A malformed CRDT update is fine here: it must get *past* authorization |
| // and fail on its own merits, which is what distinguishes "refused" from |
| // "not a valid update". |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: bob, Subject: "bob@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(bob, "bob@example.com"), []byte{0}) |
| if errors.Is(err, ErrNotWritable) { |
| t.Errorf("ApplyUpdate as a writer was refused authorization; the grant's role is doing nothing") |
| } |
| @@ -98,7 +98,7 @@ func TestApplyUpdateAllowsTheOwner(t *testing.T) { |
| t.Fatalf("Create: %v", err) |
| } |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: alice, Subject: "alice@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(alice, "alice@example.com"), []byte{0}) |
| if errors.Is(err, ErrNotWritable) { |
| t.Errorf("the owner was refused a write to her own document: %v", err) |
| } |
| @@ -121,7 +121,7 @@ func TestApplyUpdateRefusesAStranger(t *testing.T) { |
| t.Fatalf("Create: %v", err) |
| } |
| - _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: carol, Subject: "carol@example.com"}, []byte{0}) |
| + _, err = store.ApplyUpdate(ctx, doc, localActor(carol, "carol@example.com"), []byte{0}) |
| if !errors.Is(err, ErrNotWritable) { |
| t.Errorf("ApplyUpdate as a stranger: got %v, want ErrNotWritable", err) |
| } |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 4894145..81402d8 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -212,7 +212,7 @@ func (h *handlers) member(r *http.Request) bool { |
| if err != nil { |
| return false |
| } |
| - return h.isMember == nil || h.isMember(id.Subject) |
| + return id.VerifiedAddress != nil && (h.isMember == nil || h.isMember(*id.VerifiedAddress)) |
| } |
| // list serves GET /: the document list, spec §6.4 and Paul's four |
diff --git a/internal/docs/home_exchange_test.go b/internal/docs/home_exchange_test.go| index 8b491f4..49ad50b 100644 |
| --- a/internal/docs/home_exchange_test.go |
| +++ b/internal/docs/home_exchange_test.go |
| @@ -204,7 +204,7 @@ func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { |
| t.Fatalf("wrong Home session: %+v", session) |
| } |
| identity, err := f.b.resolver.resolve(sessions.WithSession(r, session)) |
| - if err != nil || identity.Actor.PersonID != person || identity.Actor.Subject != "one@example.com" { |
| + if err != nil || identity.Actor.PersonID != person || (identity.Actor.VerifiedAddress == nil || *identity.Actor.VerifiedAddress != "one@example.com") { |
| t.Fatalf("identity=%+v %v", identity, err) |
| } |
| if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil { |
diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go| index 59ec79f..04d5430 100644 |
| --- a/internal/docs/home_router_integration_test.go |
| +++ b/internal/docs/home_router_integration_test.go |
| @@ -220,7 +220,7 @@ func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *tes |
| owner = bob |
| address = "bob@example.com" |
| } |
| - if _, err = s.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: address}, ReadFixture(t, "hello")); err != nil { |
| + if _, err = s.ApplyUpdate(ctx, doc, localActor(owner, address), ReadFixture(t, "hello")); err != nil { |
| t.Fatal(err) |
| } |
| } |
diff --git a/internal/docs/identity.go b/internal/docs/identity.go| index 6fdae35..2fa28eb 100644 |
| --- a/internal/docs/identity.go |
| +++ b/internal/docs/identity.go |
| @@ -2,7 +2,9 @@ package docs |
| import ( |
| "context" |
| + "database/sql" |
| "errors" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| "net/http" |
| "time" |
| @@ -21,8 +23,9 @@ var ( |
| // requestIdentity keeps the verified session separate from the local person |
| // and address that own documents and hold grants. |
| type requestIdentity struct { |
| - Actor Actor |
| - Session sessions.Session |
| + Actor Actor |
| + HomeSubject string |
| + Session sessions.Session |
| } |
| type identityContextKey struct{} |
| @@ -32,6 +35,7 @@ func currentIdentity(r *http.Request) (requestIdentity, bool) { |
| } |
| type identityResolver struct { |
| + team *teamRuntime |
| store *Store |
| issuer string |
| // Return zero for an unknown link. Errors must not contain identity values. |
| @@ -44,6 +48,16 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| return requestIdentity{}, errIdentityRefused |
| } |
| if session.Method == homeAssertionMethod { |
| + if v.team != nil { |
| + p, err := v.team.principals.Resolve(r.Context(), v.team.bindings.Issuer, session.Subject) |
| + if errors.Is(err, principalstore.ErrRefused) { |
| + return requestIdentity{}, errIdentityRefused |
| + } |
| + if err != nil { |
| + return requestIdentity{}, errIdentityStorage |
| + } |
| + return requestIdentity{Actor: Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress}, Session: session, HomeSubject: session.Subject}, nil |
| + } |
| if v.issuer == "" || v.linkedPerson == nil { |
| return requestIdentity{}, errIdentityRefused |
| } |
| @@ -68,7 +82,10 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| if address == "" { |
| return requestIdentity{}, errIdentityRefused |
| } |
| - return requestIdentity{Actor: Actor{PersonID: p.ID, Subject: address}, Session: session}, nil |
| + return requestIdentity{Actor: localActor(p.ID, address), Session: session}, nil |
| + } |
| + if v.team != nil && session.Method != "magiclink" && session.Method != "keymail" { |
| + return requestIdentity{}, errIdentityRefused |
| } |
| // The local authentication plugin verified this address. Preserve its |
| // existing get-or-create behavior, including opt-in sample seeding. |
| @@ -76,7 +93,22 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| if err != nil { |
| return requestIdentity{}, errIdentityStorage |
| } |
| - return requestIdentity{Actor: Actor{PersonID: person, Subject: session.Subject}, Session: session}, nil |
| + id := requestIdentity{Actor: localActor(person, session.Subject), Session: session} |
| + if v.team != nil { |
| + p, err := v.team.principals.Person(r.Context(), person) |
| + if err != nil { |
| + return requestIdentity{}, errIdentityStorage |
| + } |
| + if p.Kind != principalstore.Local { |
| + return requestIdentity{}, errIdentityRefused |
| + } |
| + id.Actor = Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress} |
| + err = v.team.db.QueryRowContext(r.Context(), "SELECT subject FROM docs_identity_links WHERE issuer=? AND person_id=?", v.team.bindings.Issuer, p.ID).Scan(&id.HomeSubject) |
| + if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| + return requestIdentity{}, errIdentityStorage |
| + } |
| + } |
| + return id, nil |
| } |
| // middleware belongs after RequireSession and before admission. A verified |
diff --git a/internal/docs/identity_test.go b/internal/docs/identity_test.go| index 2e9bd62..dc0fd88 100644 |
| --- a/internal/docs/identity_test.go |
| +++ b/internal/docs/identity_test.go |
| @@ -99,7 +99,7 @@ func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) { |
| end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| reached = true |
| a, err := h.actor(r) |
| - if err != nil || a.PersonID != alice || a.Subject != "alice@example.com" { |
| + if err != nil || a.PersonID != alice || (a.VerifiedAddress == nil || *a.VerifiedAddress != "alice@example.com") { |
| t.Fatal("linked actor changed", a, err) |
| } |
| id, err := h.owner(r) |
| @@ -140,7 +140,7 @@ func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) { |
| t.Fatal(err) |
| } |
| id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod)) |
| - if err != nil || id.Actor.Subject != "renamed@example.com" { |
| + if err != nil || (id.Actor.VerifiedAddress == nil || *id.Actor.VerifiedAddress != "renamed@example.com") { |
| t.Fatal("stale local address") |
| } |
| } |
diff --git a/internal/docs/principalstore/testdata/compat_main.go.txt b/internal/docs/principalstore/testdata/compat_main.go.txt| index a4d3ddd..63c67e7 100644 |
| --- a/internal/docs/principalstore/testdata/compat_main.go.txt |
| +++ b/internal/docs/principalstore/testdata/compat_main.go.txt |
| @@ -51,7 +51,7 @@ func main() { |
| sqid = doc.Sqid |
| update, e := os.ReadFile("fixtures/vectors/fragment/hello.update.bin") |
| must(e) |
| - _, e = app.Store().ApplyUpdate(ctx, doc, docs.Actor{PersonID: id, Subject: *address}, update) |
| + _, e = app.Store().ApplyUpdate(ctx, doc, fixtureActor(id, *address), update) |
| must(e) |
| must(app.Store().Grant(ctx, id, sqid, "reader@example.invalid", docs.RoleWriter)) |
| _, e = d.Writer().Exec(`INSERT INTO doc_snapshots(doc_id,seq,state,abi_version,created_at)VALUES(?,1,?,?,CURRENT_TIMESTAMP)`, doc.ID, update, core.ABIVersion) |
diff --git a/internal/docs/principalstore/testdata/compat_new.go.txt b/internal/docs/principalstore/testdata/compat_new.go.txt| index 835cbb1..b55d4a8 100644 |
| --- a/internal/docs/principalstore/testdata/compat_new.go.txt |
| +++ b/internal/docs/principalstore/testdata/compat_new.go.txt |
| @@ -2,6 +2,7 @@ package main |
| import ( |
| "context" |
| + "github.com/carlosframework/docs/internal/docs" |
| "github.com/carlosframework/docs/internal/docs/linkstore" |
| p "github.com/carlosframework/docs/internal/docs/principalstore" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -20,3 +21,7 @@ func principalStep(ctx context.Context, d *db.DB, phase string) int64 { |
| } |
| return v.ID |
| } |
| + |
| +func fixtureActor(id int64, address string) docs.Actor { |
| + return docs.Actor{PersonID: id, VerifiedAddress: &address} |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_old.go.txt b/internal/docs/principalstore/testdata/compat_old.go.txt| index cc7b454..a0143f4 100644 |
| --- a/internal/docs/principalstore/testdata/compat_old.go.txt |
| +++ b/internal/docs/principalstore/testdata/compat_old.go.txt |
| @@ -2,7 +2,12 @@ package main |
| import ( |
| "context" |
| + "github.com/carlosframework/docs/internal/docs" |
| "github.com/carlosframework/rastrillo/db" |
| ) |
| func principalStep(context.Context, *db.DB, string) int64 { return 0 } |
| + |
| +func fixtureActor(id int64, address string) docs.Actor { |
| + return docs.Actor{PersonID: id, Subject: address} |
| +} |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 5abefeb..c283b89 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -21,6 +21,7 @@ import ( |
| // the update log, and the document's actual text read back through |
| // the real CRDT core rather than any column here — see Text. |
| type Store struct { |
| + team *teamRuntime |
| db *gorm.DB |
| core *core.Runtime |
| content *contentCipher |
| @@ -260,7 +261,7 @@ func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string) |
| if s.seedOnlyMembers != nil && !s.seedOnlyMembers(email) { |
| return |
| } |
| - if err := Seed(ctx, s, Actor{PersonID: newOwner, Subject: email}); err != nil { |
| + if err := Seed(ctx, s, localActor(newOwner, email)); err != nil { |
| s.logger.Error("docs: seed sample documents for new person", "person", newOwner, "address", email, "err", err) |
| } |
| } |
diff --git a/internal/docs/team_config.go b/internal/docs/team_config.go| new file mode 100644 |
| index 0000000..38ee412 |
| --- /dev/null |
| +++ b/internal/docs/team_config.go |
| @@ -0,0 +1,116 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/base64" |
| + "errors" |
| + |
| + "amadan.net/oficina/home/handoff/seal" |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +// TeamConfig is explicit deployment configuration, never browser request input. |
| +type TeamConfig struct { |
| + TeamID string |
| + DirectoryOrigin string |
| + ProvisioningBase64 string |
| + OperatorKeysBase64 string |
| + RosterToken string |
| +} |
| + |
| +var errTeamConfig = errors.New("docs: invalid team configuration") |
| +var errTeamUnavailable = errors.New("docs: team authority unavailable") |
| + |
| +type teamSettings struct { |
| + record roster.VerifiedRecord |
| + token string |
| + fingerprint string |
| +} |
| + |
| +var TeamBootSchema = migrate.Merge(BootSchema, principalstore.Schema, teamstate.Schema) |
| + |
| +func validatedTeamConfig(cfg *TeamConfig, home *homeSettings, origin string) (*teamSettings, error) { |
| + if cfg == nil { |
| + return nil, nil |
| + } |
| + if home == nil || !roster.ValidChallenge(cfg.RosterToken) { |
| + return nil, errTeamConfig |
| + } |
| + raw, ok := homeConfigBase64(cfg.ProvisioningBase64, roster.BodyLimit) |
| + if !ok { |
| + return nil, errTeamConfig |
| + } |
| + encoded, ok := homeConfigBase64(cfg.OperatorKeysBase64, 4096) |
| + if !ok { |
| + return nil, errTeamConfig |
| + } |
| + var keys []roster.Key |
| + if json.Unmarshal(encoded, &keys, json.RejectUnknownMembers(true)) != nil { |
| + return nil, errTeamConfig |
| + } |
| + expected := roster.Bindings{Issuer: home.issuer, Directory: cfg.DirectoryOrigin, Consumer: home.consumerID, App: "docs", Team: cfg.TeamID, Audience: origin} |
| + record, e := roster.VerifyProvisioning(raw, keys, expected) |
| + if e != nil { |
| + return nil, errTeamConfig |
| + } |
| + b, e := json.Marshal(record.Bindings()) |
| + if e != nil { |
| + return nil, errTeamConfig |
| + } |
| + digest := sha256.Sum256(b) |
| + return &teamSettings{record: record, token: cfg.RosterToken, fingerprint: base64.RawURLEncoding.EncodeToString(digest[:])}, nil |
| +} |
| + |
| +type teamRuntime struct { |
| + cache *roster.Cache |
| + principals *principalstore.Store |
| + bindings roster.Bindings |
| + db *sql.DB |
| +} |
| + |
| +func newTeamRuntime(ctx context.Context, settings *teamSettings, db *sql.DB, home *homeSettings) (*teamRuntime, error) { |
| + if settings == nil || db == nil || home == nil { |
| + return nil, errTeamConfig |
| + } |
| + b := settings.record.Bindings() |
| + var foreign int |
| + if e := db.QueryRowContext(ctx, "SELECT (SELECT COUNT(*) FROM docs_identity_links WHERE issuer!=?)+(SELECT COUNT(*) FROM docs_home_identities WHERE issuer!=?)", b.Issuer, b.Issuer).Scan(&foreign); e != nil { |
| + return nil, errTeamUnavailable |
| + } |
| + if foreign != 0 { |
| + return nil, errTeamConfig |
| + } |
| + if e := teamstate.Activate(ctx, db, settings.fingerprint); e != nil { |
| + return nil, errTeamConfig |
| + } |
| + sealer, e := seal.New(home.dek, "oficina/docs") |
| + if e != nil { |
| + return nil, errTeamConfig |
| + } |
| + cache, e := roster.New(ctx, roster.Config{Record: settings.record, Token: settings.token, Durable: teamstate.Evidence{DB: db}, Sealer: sealer}) |
| + if e != nil { |
| + return nil, errTeamUnavailable |
| + } |
| + runtime := &teamRuntime{cache: cache, bindings: b, db: db} |
| + principals, e := principalstore.New(db, principalstore.Config{Issuer: b.Issuer, Team: b.Team, RequireMembership: func(ctx context.Context, request principalstore.MembershipRequest) error { |
| + d := cache.Check(ctx, request.Issuer, request.Team, request.Subject) |
| + if d.State == roster.Member && d.Lease.Valid() { |
| + return nil |
| + } |
| + if d.State == roster.NotMember { |
| + return principalstore.ErrRefused |
| + } |
| + return principalstore.ErrUnavailable |
| + }}) |
| + if e != nil { |
| + return nil, errTeamUnavailable |
| + } |
| + runtime.principals = principals |
| + return runtime, nil |
| +} |
diff --git a/internal/docs/team_identity_test.go b/internal/docs/team_identity_test.go| new file mode 100644 |
| index 0000000..02800b5 |
| --- /dev/null |
| +++ b/internal/docs/team_identity_test.go |
| @@ -0,0 +1,133 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/docs/internal/docs/principalstore" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/go-json-experiment/json" |
| + "gorm.io/gorm" |
| +) |
| + |
| +func teamConfigFixture(t *testing.T) (*TeamConfig, *homeSettings) { |
| + t.Helper() |
| + op, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + key, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"} |
| + p := roster.Provisioning{Version: 1, Operation: "provision1", Sequence: 1, Bindings: b, Generation: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)), MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(key.SignPub())}}} |
| + raw, e := roster.SignProvisioning(op, "operator1", p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}) |
| + return &TeamConfig{TeamID: b.Team, DirectoryOrigin: b.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(raw), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}, &homeSettings{issuer: b.Issuer, consumerID: b.Consumer, dek: bytes.Repeat([]byte{9}, 32)} |
| +} |
| +func TestTeamConfigPinsAndColdMarker(t *testing.T) { |
| + cfg, home := teamConfigFixture(t) |
| + settings, e := validatedTeamConfig(cfg, home, "https://docs.example") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, change := range []func(*TeamConfig){func(c *TeamConfig) { c.TeamID = "Other" }, func(c *TeamConfig) { c.DirectoryOrigin = "https://other.example" }, func(c *TeamConfig) { c.RosterToken += "=" }, func(c *TeamConfig) { c.ProvisioningBase64 += "\n" }, func(c *TeamConfig) { c.OperatorKeysBase64 = "e30=" }} { |
| + bad := *cfg |
| + change(&bad) |
| + if _, e := validatedTeamConfig(&bad, home, "https://docs.example"); e != errTeamConfig { |
| + t.Fatal("bad team config") |
| + } |
| + } |
| + if _, e := validatedTeamConfig(cfg, nil, "https://docs.example"); e != errTeamConfig { |
| + t.Fatal("team without Home") |
| + } |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + if _, e := migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, e := newTeamRuntime(t.Context(), settings, app.db.Writer(), home) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if rt.cache == nil || rt.principals == nil { |
| + t.Fatal("runtime missing") |
| + } |
| + if got, yes, e := teamstate.Required(t.Context(), app.db.Writer()); e != nil || !yes || got != settings.fingerprint { |
| + t.Fatal("runtime did not commit marker") |
| + } |
| + var count int |
| + _ = app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_home_identities").Scan(&count) |
| + if count != 0 { |
| + t.Fatal("runtime seeded identity") |
| + } |
| +} |
| +func TestTeamIdentityAddressAbsenceNeverRedeemsEmptyGrant(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + ctx := context.Background() |
| + s := app.Store() |
| + local, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, local, "private") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = migrate.Apply(ctx, app.db, TeamBootSchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + ps, e := principalstore.New(app.db.Writer(), principalstore.Config{Issuer: "https://id.example", Team: "TeamA", RequireMembership: func(context.Context, principalstore.MembershipRequest) error { return nil }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + subject := "pr_" + strings.Repeat("a", 26) |
| + p, e := ps.EnsureHome(ctx, principalstore.HomeIdentity{Issuer: "https://id.example", Subject: subject}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt := &teamRuntime{principals: ps, bindings: roster.Bindings{Issuer: "https://id.example", Team: "TeamA"}, db: app.db.Writer()} |
| + s.team = rt |
| + resolver := identityResolver{store: s, team: rt} |
| + id, e := resolver.resolve(identityRequest("GET", subject, homeAssertionMethod)) |
| + if e != nil || id.Actor.PersonID != p.ID || id.Actor.VerifiedAddress != nil || id.HomeSubject != subject { |
| + t.Fatal("wrong Home principal", e) |
| + } |
| + if e = s.db.Create(&Grant{DocID: doc.ID, Subject: "", Role: RoleWriter, CreatedBy: local}).Error; e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = s.GetFor(ctx, id.Actor, doc.Sqid); !errors.Is(e, gorm.ErrRecordNotFound) { |
| + t.Fatal("nil address redeemed malformed grant", e) |
| + } |
| + if s.CanWrite(ctx, id.Actor, doc.ID) { |
| + t.Fatal("nil address redeemed malformed writer grant") |
| + } |
| + own, e := s.Create(ctx, p.ID, "Home owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = s.Grant(ctx, p.ID, own.Sqid, "guest@example.com", RoleReader); e != nil { |
| + t.Fatal("addressless owner could not grant", e) |
| + } |
| + var people int64 |
| + s.db.Model(&Person{}).Count(&people) |
| + if people != 1 { |
| + t.Fatal("Home identity created legacy person") |
| + } |
| + if _, e = resolver.resolve(identityRequest("GET", "pr_"+strings.Repeat("b", 26), homeAssertionMethod)); e != errIdentityRefused { |
| + t.Fatal("unknown identity allocated") |
| + } |
| + if _, e = resolver.resolve(identityRequest("GET", "unknown@example.com", "unsupported")); e != errIdentityRefused { |
| + t.Fatal("unsupported local method accepted") |
| + } |
| +} |
diff --git a/internal/docs/teamstate/migrations/0001_team.sql b/internal/docs/teamstate/migrations/0001_team.sql| new file mode 100644 |
| index 0000000..637b599 |
| --- /dev/null |
| +++ b/internal/docs/teamstate/migrations/0001_team.sql |
| @@ -0,0 +1,10 @@ |
| +CREATE TABLE docs_team_required ( |
| + id INTEGER PRIMARY KEY CHECK(id=1), |
| + bindings TEXT NOT NULL CHECK(typeof(bindings)='text' AND length(bindings)=43) |
| +); |
| +CREATE TRIGGER docs_team_required_keep_update BEFORE UPDATE ON docs_team_required BEGIN SELECT RAISE(ABORT,'docs team: refused'); END; |
| +CREATE TRIGGER docs_team_required_keep_delete BEFORE DELETE ON docs_team_required BEGIN SELECT RAISE(ABORT,'docs team: refused'); END; |
| +CREATE TABLE docs_team_roster_evidence ( |
| + id INTEGER PRIMARY KEY CHECK(id=1), |
| + payload BLOB NOT NULL CHECK(typeof(payload)='blob' AND length(payload)<=1048704) |
| +); |
diff --git a/internal/docs/teamstate/store.go b/internal/docs/teamstate/store.go| new file mode 100644 |
| index 0000000..00d016a |
| --- /dev/null |
| +++ b/internal/docs/teamstate/store.go |
| @@ -0,0 +1,98 @@ |
| +// Package teamstate keeps the mandatory-mode marker and opaque roster evidence. |
| +package teamstate |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "embed" |
| + "encoding/base64" |
| + "errors" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| +var Schema = migrate.MustFromFS(migrations, "docs_team") |
| +var ErrRefused = errors.New("docs team: refused") |
| +var ErrUnavailable = errors.New("docs team: unavailable") |
| + |
| +func validBinding(s string) bool { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| + |
| +// Required distinguishes a never-activated database from an unreadable marker. |
| +func Required(ctx context.Context, db *sql.DB) (string, bool, error) { |
| + var count int |
| + if e := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='docs_team_required'").Scan(&count); e != nil { |
| + return "", false, ErrUnavailable |
| + } |
| + if count == 0 { |
| + return "", false, nil |
| + } |
| + var value string |
| + e := db.QueryRowContext(ctx, "SELECT bindings FROM docs_team_required WHERE id=1").Scan(&value) |
| + if errors.Is(e, sql.ErrNoRows) { |
| + return "", false, nil |
| + } |
| + if e != nil { |
| + return "", false, ErrUnavailable |
| + } |
| + if !validBinding(value) { |
| + return "", false, ErrRefused |
| + } |
| + return value, true, nil |
| +} |
| + |
| +// Activate commits before the caller may serve requests or allocate identities. |
| +// It records an already-verified deployment binding, never an owner or roster. |
| +func Activate(ctx context.Context, db *sql.DB, binding string) error { |
| + if !validBinding(binding) { |
| + return ErrRefused |
| + } |
| + tx, e := db.BeginTx(ctx, nil) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + if _, e = tx.ExecContext(ctx, "INSERT INTO docs_team_required(id,bindings) VALUES(1,?) ON CONFLICT(id) DO NOTHING", binding); e != nil { |
| + return ErrUnavailable |
| + } |
| + var got string |
| + if e = tx.QueryRowContext(ctx, "SELECT bindings FROM docs_team_required WHERE id=1").Scan(&got); e != nil { |
| + return ErrUnavailable |
| + } |
| + if got != binding { |
| + return ErrRefused |
| + } |
| + if tx.Commit() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +type Evidence struct{ DB *sql.DB } |
| + |
| +func (s Evidence) Load(ctx context.Context) ([]byte, error) { |
| + var b []byte |
| + e := s.DB.QueryRowContext(ctx, "SELECT payload FROM docs_team_roster_evidence WHERE id=1").Scan(&b) |
| + if errors.Is(e, sql.ErrNoRows) { |
| + return nil, roster.ErrNoEvidence |
| + } |
| + if e != nil || len(b) > roster.EvidenceLimit+128 { |
| + return nil, ErrUnavailable |
| + } |
| + return b, nil |
| +} |
| +func (s Evidence) Save(ctx context.Context, b []byte) error { |
| + if len(b) == 0 || len(b) > roster.EvidenceLimit+128 { |
| + return ErrRefused |
| + } |
| + _, e := s.DB.ExecContext(ctx, "INSERT INTO docs_team_roster_evidence(id,payload) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload", b) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/teamstate/store_test.go b/internal/docs/teamstate/store_test.go| new file mode 100644 |
| index 0000000..097ec5f |
| --- /dev/null |
| +++ b/internal/docs/teamstate/store_test.go |
| @@ -0,0 +1,89 @@ |
| +package teamstate |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/roster" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +func fixture(t *testing.T) *db.DB { |
| + t.Helper() |
| + d, e := db.Open(filepath.Join(t.TempDir(), "team.db"), nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + return d |
| +} |
| +func TestMarkerActivationAndImmutability(t *testing.T) { |
| + d := fixture(t) |
| + ctx := context.Background() |
| + if _, yes, e := Required(ctx, d.Writer()); e != nil || yes { |
| + t.Fatal("fresh DB requires team") |
| + } |
| + if _, e := migrate.Apply(ctx, d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, yes, e := Required(ctx, d.Writer()); e != nil || yes { |
| + t.Fatal("schema alone activated mode") |
| + } |
| + b := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) |
| + if e := Activate(ctx, d.Writer(), b); e != nil { |
| + t.Fatal(e) |
| + } |
| + if got, yes, e := Required(ctx, d.Writer()); e != nil || !yes || got != b { |
| + t.Fatal("marker missing") |
| + } |
| + if e := Activate(ctx, d.Writer(), b); e != nil { |
| + t.Fatal("idempotent activation", e) |
| + } |
| + other := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)) |
| + if Activate(ctx, d.Writer(), other) != ErrRefused { |
| + t.Fatal("binding changed") |
| + } |
| + for _, q := range []string{"DELETE FROM docs_team_required", "UPDATE docs_team_required SET bindings=bindings"} { |
| + if _, e := d.Writer().Exec(q); e == nil { |
| + t.Fatal("marker mutable") |
| + } |
| + } |
| +} |
| +func TestMalformedMarkerAndEvidenceFailure(t *testing.T) { |
| + d := fixture(t) |
| + ctx := context.Background() |
| + if _, e := migrate.Apply(ctx, d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := d.Writer().Exec("INSERT INTO docs_team_required VALUES(1,?)", strings.Repeat("?", 43)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, _, e := Required(ctx, d.Writer()); e != ErrRefused { |
| + t.Fatal("malformed marker accepted") |
| + } |
| + evidence := Evidence{DB: d.Writer()} |
| + if _, e := evidence.Load(ctx); e != roster.ErrNoEvidence { |
| + t.Fatal(e) |
| + } |
| + first := []byte("opaque sealed test envelope") |
| + if e := evidence.Save(ctx, first); e != nil { |
| + t.Fatal(e) |
| + } |
| + cancelled, cancel := context.WithCancel(ctx) |
| + cancel() |
| + if evidence.Save(cancelled, []byte("replacement")) == nil { |
| + t.Fatal("cancelled replacement succeeded") |
| + } |
| + if evidence.Save(ctx, make([]byte, roster.EvidenceLimit+129)) != ErrRefused { |
| + t.Fatal("oversize accepted") |
| + } |
| + got, e := evidence.Load(ctx) |
| + if e != nil || !bytes.Equal(got, first) { |
| + t.Fatal("failed write replaced evidence") |
| + } |
| +} |
diff --git a/internal/docs/testing.go b/internal/docs/testing.go| index 29562f2..7ff32a8 100644 |
| --- a/internal/docs/testing.go |
| +++ b/internal/docs/testing.go |
| @@ -279,7 +279,7 @@ func (a *App) TestActorForPerson(t *testing.T, person int64) Actor { |
| if err := a.Store().db.Where("id = ?", person).First(&p).Error; err != nil { |
| t.Fatalf("TestActorForPerson: resolving person %d: %v", person, err) |
| } |
| - return Actor{PersonID: p.ID, Subject: p.Email} |
| + return localActor(p.ID, p.Email) |
| } |
| // TestEditorScriptPath is the served path of the editor bundle, exported so |