oficina / home Public

Bound pending roster reads to audited verification windows

Paul Campbell pushed by paul@keymail.dev 0c27976c61e956c2fbae0dd836f7bf7ab8cef157 parent e20fd41
5 files changed, +298 −2
  • internal/home/directorydata/roster.go +4 −1
  • internal/home/directorydata/roster_pending.go +29 −0
  • internal/home/directorydata/roster_pending_test.go +197 −0
  • internal/home/directorydata/roster_test.go +6 −1
  • internal/home/roster_pending_integration_test.go +62 −0
diff --git a/internal/home/directorydata/roster.go b/internal/home/directorydata/roster.go
index 2023d7f..70af8dd 100644
--- a/internal/home/directorydata/roster.go
+++ b/internal/home/directorydata/roster.go
@@ -107,7 +107,7 @@ func (s *RosterSource) Read(ctx context.Context, req RosterRequest, now time.Tim
if req.Team != route.TeamSQID {
return fail(404, ErrRosterNotFound)
}
- if state != "live" || !s.store.apps[route.App] || !ValidOrigin(route.Origin) || !validConsumer(route.ConsumerID) || !validLabel(route.Label) || !s.store.validateRoute(route) {
+ if (state != "live" && state != "pending") || !s.store.apps[route.App] || !ValidOrigin(route.Origin) || !validConsumer(route.ConsumerID) || !validLabel(route.Label) || !s.store.validateRoute(route) {
return fail(503, ErrUnavailable)
}
p := roster.Page{Version: 1, Type: "page", Directory: s.directory, Consumer: route.ConsumerID, Team: route.TeamSQID, Audience: route.Origin, Fetch: req.Fetch, RequestCursorHash: roster.CursorHash(req.Cursor)}
@@ -115,6 +115,9 @@ func (s *RosterSource) Read(ctx context.Context, req RosterRequest, now time.Tim
if err != nil || !roster.ValidChallenge(p.Generation) || p.Epoch <= 0 {
return fail(503, ErrUnavailable)
}
+ if state == "pending" && !pendingRosterAllowed(ctx, tx, route, p.Generation, now.Unix()) {
+ return fail(503, ErrUnavailable)
+ }
index := 0
last := ""
if req.Cursor != "" {
diff --git a/internal/home/directorydata/roster_pending.go b/internal/home/directorydata/roster_pending.go
new file mode 100644
index 0000000..5d8341d
--- /dev/null
+++ b/internal/home/directorydata/roster_pending.go
@@ -0,0 +1,29 @@
+package directorydata
+
+import (
+ "context"
+ "database/sql"
+)
+
+// pendingRosterAllowed observes the operator-owned window in the roster read
+// transaction. The window never renews here; every page checks its absolute end.
+func pendingRosterAllowed(ctx context.Context, tx *sql.Tx, route Route, generation string, now int64) bool {
+ var issued, expires int64
+ var owner string
+ err := tx.QueryRowContext(ctx, `SELECT a.issued_at,a.expires_at,a.owner_ref
+ FROM verification_active v
+ JOIN verification_attempts a ON a.operation_id=v.operation_id AND a.consumer_id=v.consumer_id
+ JOIN directory_operator_consumer_records r ON r.consumer_id=a.consumer_id
+ JOIN roster_operator_receipts receipt ON receipt.operation_id=r.operation_id
+ JOIN memberships m ON m.team_id=a.team_id AND m.person_ref=a.owner_ref AND m.role='owner'
+ WHERE v.consumer_id=? AND a.team_id=? AND a.app=? AND a.origin=? AND a.generation=?
+ AND r.generation=a.generation AND r.sequence=a.record_sequence AND r.record_digest=a.record_digest
+ AND typeof(a.issued_at)='integer' AND typeof(a.expires_at)='integer'
+ AND a.expected_epoch>0 AND typeof(a.expected_epoch)='integer'
+ AND length(a.authorization_digest)=32 AND typeof(a.authorization_digest)='blob'
+ AND r.sequence>0 AND length(r.record_digest)=32 AND typeof(r.record_digest)='blob'
+ AND receipt.consumer_id=r.consumer_id AND receipt.sequence=r.sequence
+ AND receipt.generation=r.generation AND receipt.record_digest=r.record_digest`,
+ route.ConsumerID, route.TeamID, route.App, route.Origin, generation).Scan(&issued, &expires, &owner)
+ return err == nil && validSubject(owner) && now > 0 && issued > 0 && issued <= now && expires > issued && expires-issued <= 600 && now < expires
+}
diff --git a/internal/home/directorydata/roster_pending_test.go b/internal/home/directorydata/roster_pending_test.go
new file mode 100644
index 0000000..b7b9a23
--- /dev/null
+++ b/internal/home/directorydata/roster_pending_test.go
@@ -0,0 +1,197 @@
+package directorydata
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "errors"
+ "math"
+ "testing"
+ "time"
+)
+
+func pendingFixture(t *testing.T) (*RosterSource, RosterRequest, string, int64) {
+ t.Helper()
+ s, r, path, team := rosterFixture(t, OperatorSchema)
+ member(t, s.store, team, personA, "owner")
+ digest := bytes.Repeat([]byte{33}, 32)
+ tx, e := s.store.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ for _, q := range []struct {
+ sql string
+ args []any
+ }{
+ {`UPDATE team_instances SET state='pending' WHERE team_id=?`, []any{team}},
+ {`INSERT INTO roster_operator_receipts VALUES('provision-alpha',1,'provision','alpha-docs',?,?,?)`, []any{challenge(3), digest, rosterNow.Unix() - 1}},
+ {`INSERT INTO directory_operator_consumer_records VALUES('alpha-docs','provision-alpha',1,?,?)`, []any{challenge(3), digest}},
+ {`INSERT INTO verification_attempts SELECT ?,'alpha-docs',id,'docs','https://alpha.docs.oficina.works',?,1,?,roster_epoch,?,?,?,? FROM teams WHERE id=?`, []any{challenge(21), challenge(3), digest, personA, rosterNow.Unix(), rosterNow.Unix() + 600, bytes.Repeat([]byte{34}, 32), team}},
+ {`INSERT INTO verification_active VALUES('alpha-docs',?)`, []any{challenge(21)}},
+ } {
+ if _, e = tx.Exec(q.sql, q.args...); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ return s, r, path, team
+}
+func pendingStatus(t *testing.T, s *RosterSource, r RosterRequest, at time.Time, want int) {
+ t.Helper()
+ p, status, e := s.Read(context.Background(), r, at)
+ if status != want {
+ t.Fatalf("status %d, want %d", status, want)
+ }
+ if want == 503 && (!errors.Is(e, ErrUnavailable) || p.Type != "") {
+ t.Fatal("pending refusal differs")
+ }
+ if want == 200 && (e != nil || p.Type != "page") {
+ t.Fatal("missing page")
+ }
+}
+func TestPendingRosterWindowBoundariesAndChooser(t *testing.T) {
+ s, r, _, _ := pendingFixture(t)
+ for _, offset := range []int64{0, 599, 600, 601} {
+ want := 200
+ if offset >= 600 {
+ want = 503
+ }
+ pendingStatus(t, s, r, rosterNow.Add(time.Duration(offset)*time.Second), want)
+ }
+ choices, e := s.store.Choices(context.Background(), personA, "docs")
+ if e != nil || len(choices) != 0 {
+ t.Fatal("pending binding visible")
+ }
+}
+func TestPendingRosterRefusesBrokenAuthority(t *testing.T) {
+ cases := []struct {
+ name, sql string
+ args []any
+ }{
+ {"missing active", `DELETE FROM verification_active`, nil},
+ {"missing pointer", `DELETE FROM directory_operator_consumer_records`, nil},
+ {"wrong generation", `UPDATE verification_attempts SET generation=?`, []any{challenge(4)}},
+ {"changed current generation", `UPDATE roster_generation SET generation=?`, []any{challenge(4)}},
+ {"wrong record sequence", `UPDATE verification_attempts SET record_sequence=2`, nil},
+ {"wrong record digest", `UPDATE verification_attempts SET record_digest=?`, []any{bytes.Repeat([]byte{5}, 32)}},
+ {"wrong pointer generation", `UPDATE directory_operator_consumer_records SET generation=?`, []any{challenge(4)}},
+ {"wrong pointer receipt", `UPDATE roster_operator_receipts SET sequence=2`, nil},
+ {"wrong owner", `UPDATE verification_attempts SET owner_ref=?`, []any{personB}},
+ {"owner removed", `DELETE FROM memberships WHERE person_ref=?`, []any{personA}},
+ {"owner demoted", `UPDATE memberships SET role='member' WHERE person_ref=?`, []any{personA}},
+ {"wrong team", `UPDATE verification_attempts SET team_id=(SELECT id FROM teams WHERE sqid='B2')`, nil},
+ {"wrong app", `UPDATE verification_attempts SET app='sheets'`, nil},
+ {"wrong origin", `UPDATE verification_attempts SET origin='https://beta.docs.oficina.works'`, nil},
+ {"future issued", `UPDATE verification_attempts SET issued_at=issued_at+1,expires_at=expires_at+1`, nil},
+ {"negative issued", `UPDATE verification_attempts SET issued_at=-1`, nil},
+ {"zero issued", `UPDATE verification_attempts SET issued_at=0`, nil},
+ {"expired", `UPDATE verification_attempts SET issued_at=issued_at-600,expires_at=expires_at-600`, nil},
+ {"long lifetime", `UPDATE verification_attempts SET expires_at=expires_at+1`, nil},
+ {"overflow lifetime", `UPDATE verification_attempts SET issued_at=?,expires_at=?`, []any{int64(-1), int64(math.MaxInt64)}},
+ {"reversed lifetime", `UPDATE verification_attempts SET expires_at=issued_at`, nil},
+ {"partial authorization", `UPDATE verification_attempts SET authorization_digest=x'00'`, nil},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s, r, _, _ := pendingFixture(t)
+ exec(t, s.store.db, `PRAGMA ignore_check_constraints=ON`)
+ exec(t, s.store.db, tc.sql, tc.args...)
+ pendingStatus(t, s, r, rosterNow, 503)
+ })
+ }
+}
+func TestPendingRosterTokenScopeAndLiveUnchanged(t *testing.T) {
+ s, r, _, team := pendingFixture(t)
+ bad := r
+ bad.Token = challenge(17)
+ pendingStatus(t, s, bad, rosterNow, 401)
+ bad = r
+ bad.Team = "B2"
+ pendingStatus(t, s, bad, rosterNow, 404)
+ bad.Team = "Missing"
+ pendingStatus(t, s, bad, rosterNow, 404)
+ token := challenge(18)
+ digest := sha256.Sum256([]byte(token))
+ exec(t, s.store.db, `UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=?`, rosterNow.Unix(), rosterNow.Unix()+300)
+ exec(t, s.store.db, `INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, digest[:], rosterNow.Unix())
+ pendingStatus(t, s, r, rosterNow.Add(299*time.Second), 200)
+ pendingStatus(t, s, r, rosterNow.Add(300*time.Second), 401)
+ r.Token = token
+ pendingStatus(t, s, r, rosterNow.Add(599*time.Second), 200)
+ exec(t, s.store.db, `UPDATE roster_consumers SET enabled=0`)
+ pendingStatus(t, s, r, rosterNow, 401)
+ exec(t, s.store.db, `UPDATE roster_consumers SET enabled=1`)
+ exec(t, s.store.db, `UPDATE team_instances SET state='live' WHERE team_id=?`, team)
+ exec(t, s.store.db, `DELETE FROM verification_active`)
+ pendingStatus(t, s, r, rosterNow.Add(601*time.Second), 200)
+}
+func TestPendingRosterEpochAndExpiryOnCursor(t *testing.T) {
+ s, r, _, team := pendingFixture(t)
+ exec(t, s.store.db, `DELETE FROM memberships WHERE team_id=?`, team)
+ addRoster(t, s, team, 200)
+ exec(t, s.store.db, `UPDATE memberships SET role='owner' WHERE team_id=? AND person_ref=?`, team, personA)
+ p, status, e := s.Read(context.Background(), r, rosterNow.Add(595*time.Second))
+ if e != nil || status != 200 || p.Cursor == nil {
+ t.Fatal("missing first page")
+ }
+ r.Cursor = *p.Cursor
+ pendingStatus(t, s, r, rosterNow.Add(599*time.Second), 200)
+ pendingStatus(t, s, r, rosterNow.Add(600*time.Second), 503)
+ r.Cursor = ""
+ p, _, e = s.Read(context.Background(), r, rosterNow)
+ if e != nil || p.Cursor == nil {
+ t.Fatal("missing cursor")
+ }
+ r.Cursor = *p.Cursor
+ member(t, s.store, team, personB, "member")
+ p, status, e = s.Read(context.Background(), r, rosterNow)
+ if e != nil || status != 409 || p.Members != nil {
+ t.Fatal("epoch drift was not conflict")
+ }
+ r.Cursor = ""
+ pendingStatus(t, s, r, rosterNow, 200)
+ exec(t, s.store.db, `DELETE FROM memberships WHERE person_ref=?`, personA)
+ pendingStatus(t, s, r, rosterNow, 503)
+}
+
+func TestPendingRosterWindowCannotCrossConsumers(t *testing.T) {
+ s, r, _, _ := pendingFixture(t)
+ var beta int64
+ if e := s.store.db.QueryRow(`SELECT id FROM teams WHERE sqid='B2'`).Scan(&beta); e != nil {
+ t.Fatal(e)
+ }
+ exec(t, s.store.db, `UPDATE team_instances SET state='pending' WHERE team_id=?`, beta)
+ exec(t, s.store.db, `INSERT INTO roster_consumers VALUES('beta-docs',?,'docs','https://beta.docs.oficina.works',1)`, beta)
+ token := challenge(19)
+ digest := sha256.Sum256([]byte(token))
+ exec(t, s.store.db, `INSERT INTO roster_tokens VALUES(?,'beta-docs','current',?,NULL)`, digest[:], rosterNow.Unix())
+ exec(t, s.store.db, `DELETE FROM verification_active`)
+ exec(t, s.store.db, `INSERT INTO verification_active VALUES('beta-docs',?)`, challenge(21))
+ r.Team = "B2"
+ r.Token = token
+ pendingStatus(t, s, r, rosterNow, 503)
+}
+
+func TestPendingRosterReadSnapshotIncludesWindow(t *testing.T) {
+ s, r, path, team := pendingFixture(t)
+ exec(t, s.store.db, `DELETE FROM memberships WHERE team_id=?`, team)
+ addRoster(t, s, team, 201)
+ exec(t, s.store.db, `UPDATE memberships SET role='owner' WHERE team_id=? AND person_ref=?`, team, personA)
+ p, _, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil || p.Cursor == nil {
+ t.Fatal("missing page")
+ }
+ r.Cursor = *p.Cursor
+ writer, e := sql.Open("sqlite", path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer writer.Close()
+ s.store.sealer = &onOpenSealer{Sealer: s.store.sealer, once: func() { exec(t, writer, `DELETE FROM verification_active`) }}
+ pendingStatus(t, s, r, rosterNow, 200)
+ pendingStatus(t, s, r, rosterNow, 503)
+}
diff --git a/internal/home/directorydata/roster_test.go b/internal/home/directorydata/roster_test.go
index cd57d2e..51e8074 100644
--- a/internal/home/directorydata/roster_test.go
+++ b/internal/home/directorydata/roster_test.go
@@ -23,12 +23,17 @@ var rosterNow = time.Unix(2000000000, 0)
func challenge(b byte) string {
return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32))
}
-func rosterFixture(t *testing.T) (*RosterSource, RosterRequest, string, int64) {
+func rosterFixture(t *testing.T, extra ...*migrate.Set) (*RosterSource, RosterRequest, string, int64) {
t.Helper()
s, d, path := fixture(t)
if _, err := migrate.Apply(context.Background(), d, RosterSchema); err != nil {
t.Fatal(err)
}
+ for _, schema := range extra {
+ if _, err := migrate.Apply(context.Background(), d, schema); err != nil {
+ t.Fatal(err)
+ }
+ }
a := team(t, s, "A1", "alpha", privateName)
instance(t, s, a, "alpha", "docs", "live")
b := team(t, s, "B2", "beta", "Other")
diff --git a/internal/home/roster_pending_integration_test.go b/internal/home/roster_pending_integration_test.go
new file mode 100644
index 0000000..dcbccdd
--- /dev/null
+++ b/internal/home/roster_pending_integration_test.go
@@ -0,0 +1,62 @@
+package home
+
+import (
+ "bytes"
+ "context"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/internal/home/sealtest"
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+func TestPendingRosterActualSignedHTTPAndLeaseCeiling(t *testing.T) {
+ f := newRosterIntegration(t, 1)
+ if _, e := migrate.Apply(context.Background(), f.f.p.directory.db, directorydata.OperatorSchema); e != nil {
+ t.Fatal(e)
+ }
+ cache, _, clock := f.cache(t, f.transport, rosterKeyID(f.key))
+ start, _ := clock.Now()
+ f.f.p.directory.directory.roster.now = func() time.Time { at, _ := clock.Now(); return at }
+ digest := bytes.Repeat([]byte{33}, 32)
+ tx, e := f.f.p.directory.db.Writer().Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ for _, q := range []struct {
+ sql string
+ args []any
+ }{
+ {`UPDATE memberships SET role='owner' WHERE team_id=? AND person_ref=?`, []any{f.team, chooserPerson}},
+ {`UPDATE team_instances SET state='pending' WHERE team_id=?`, []any{f.team}},
+ {`INSERT INTO roster_operator_receipts VALUES('provision-alpha',1,'provision','alpha-docs',?,?,?)`, []any{f.generation, digest, start.Unix()}},
+ {`INSERT INTO directory_operator_consumer_records VALUES('alpha-docs','provision-alpha',1,?,?)`, []any{f.generation, digest}},
+ {`INSERT INTO verification_attempts SELECT ?,'alpha-docs',id,'docs','https://alpha.docs.oficina.works',?,1,?,roster_epoch,?,?,?,? FROM teams WHERE id=?`, []any{rosterAtom(21), f.generation, digest, chooserPerson, start.Unix(), start.Unix() + 600, bytes.Repeat([]byte{34}, 32), f.team}},
+ {`INSERT INTO verification_active VALUES('alpha-docs',?)`, []any{rosterAtom(21)}},
+ } {
+ if _, e = tx.Exec(q.sql, q.args...); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ clock.advance(599 * time.Second)
+ d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
+ if d.State != roster.Member || !d.Lease.Valid() {
+ t.Fatal("pending signed snapshot not accepted", d.State)
+ }
+ clock.advance(time.Second)
+ status, _, b := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
+ if status != 503 || string(b) != `{"error":"unavailable"}` {
+ t.Fatal("expired pending differs", status)
+ }
+ clock.advance(59 * time.Second)
+ if next := cache.Check(context.Background(), Origin, "A1", chooserPerson); next.State != roster.Unknown || d.Lease.Valid() {
+ t.Fatal("pending expiry extended standing", next.State)
+ }
+ sealtest.AssertNoPlaintext(t, f.f.p.directoryPath)
+}