oficina / home Public

Add unmounted directory roster snapshot storage

Paul Campbell pushed by paul@keymail.dev d9b405b6ab27ead16d0db696c23cb131a4a0ed93 parent 9a4ae5b
6 files changed, +699 −7
  • go.mod +1 −1
  • go.sum +2 −6
  • implementation/roster-producer-storage.md +76 −0
  • internal/home/directorydata/roster.go +194 −0
  • internal/home/directorydata/roster_migrations/migrations/0001_roster.sql +43 −0
  • internal/home/directorydata/roster_test.go +383 −0
diff --git a/go.mod b/go.mod
index 476aac2..96144de 100644
--- a/go.mod
+++ b/go.mod
@@ -5,12 +5,12 @@ go 1.25.0
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-20260908115900-667dc8218dd5
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/keymaildev/signin v0.1.1
- golang.org/x/crypto v0.55.0
gorm.io/plugin/dbresolver v1.6.2
modernc.org/sqlite v1.55.0
)
diff --git a/go.sum b/go.sum
index dc6b1f1..d96b208 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +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-20260908081549-3c550c3491db h1:3aN3BlNC03KJuFEk/C7EFEprt0Csto/LFjdwYeDv0FM=
-amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
-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-20260908115900-667dc8218dd5 h1:MQElRkuUPK46BWA3V3CVgJC9robI8X7LR0N67Jb3EZI=
+amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5/go.mod h1:nhhPCCPaiXfZfQheJEOzKL8beyHkJjrag8vfmu/DcWU=
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=
@@ -50,8 +48,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
-golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
diff --git a/implementation/roster-producer-storage.md b/implementation/roster-producer-storage.md
new file mode 100644
index 0000000..31d442e
--- /dev/null
+++ b/implementation/roster-producer-storage.md
@@ -0,0 +1,76 @@
+# Roster producer storage integration contract
+
+Scope: discussion 33, implementing the approved roster contract in discussion 28.
+This branch adds no mounted schema, route, operator command or production data.
+
+`directorydata.RosterSchema` follows `directorydata.Schema` in a future directory
+schema merge. It creates empty generation, consumer, token and receipt tables.
+`NewRosterSource(db, sealer, directoryConfig, directoryOrigin)` copies the existing
+application and route policy. `Read(ctx, RosterRequest, now)` returns the shared
+`roster.Page`, HTTP status and a fixed error. A successful 409 has a nil error and
+no member fields. The HTTP caller must sign both 200 and 409 with shared
+`roster.SignPage`; no raw Page is a trusted wire response.
+
+The HTTP integration still owns exact Host enforcement, method/query/header
+shape and length checks, no-store on every response, fixed response bodies,
+no request-URL logging, and roster-only signing keys. `Read` has no outbound
+transport. All scope comes from the token's consumer and its matching live
+registry row. A requested team only compares against that scope.
+
+## Future audited writer transaction
+
+There is deliberately no public mutation API. The following are preconditions
+for the root-owned operator integration, not authority conferred by these tables.
+Before opening its write transaction, authenticate and authorize the operation
+using the separately reviewed operator authority. Verify the operation ID,
+monotonic record sequence within its consumer scope, exact directory, consumer, team, app, audience and
+expected current generation. Refuse replay, changed payload under an old ID,
+and unknown authority. Never accept browser identity or bearer possession as
+operator authority. Recheck expected stored state under the transaction lock.
+
+Within the same transaction, write the intended registry/consumer/token or
+generation change and its successful receipt. The receipt retains only opaque
+operation ID, sequence, fixed operation kind, optional consumer ID, generation,
+SHA256 record digest and completion time. It is not a signed provisioning record
+and cannot approve a client generation. The operator path must deliver the
+separately signed record through the approved transport. No token or team name
+belongs in the receipt. Name sealing remains the existing ID-bound helper.
+
+Token slots are normalized rows with a globally unique SHA256 hash of the exact
+canonical 43-character bearer string. This prevents cross-consumer and cross-slot
+collisions. To rotate, delete an older previous slot, change the current row to
+previous with issued_at equal to the rotation time and expires_at at most 300
+seconds later, and insert the new current hash. Here issued_at starts the slot's
+validity window; it is not the original token creation timestamp. Current slots
+have no expiry. Future-dated slots fail. Disable by setting enabled=0 in the
+same audited transaction; no read changes slots or extends an overlap.
+
+Ordinary boot never fills generation. Bootstrap needs the independently approved
+fresh-install path. Restore needs the platform's fenced maintenance path and a
+new random generation plus newly approved records before traffic resumes.
+Directory storage alone does not supply either path. Generation and receipt
+values must be canonical and exact when written; the reader also rejects a
+malformed generation. Consumers bind to a matching registry consumer/team/app/
+origin and the injected app/route policy; pending or disabled instances do not
+serve rosters.
+
+Membership INSERT, UPDATE and DELETE triggers advance affected teams' epochs in
+the writing transaction and abort on signed 64-bit overflow. The future writer
+must not add a second manual increment. Moving membership bumps both teams once;
+changing a role bumps one. Even a no-op UPDATE bumps its team. Do not lower an
+epoch or bypass triggers. Restore is the separate generation-fenced procedure.
+
+## Snapshot and cursor
+
+One read transaction resolves the token, enabled/overlap state, live binding,
+generation, epoch, count and ordered page. It does not keep a server snapshot
+between requests. A membership write after that transaction begins may appear
+on the next call; the consumer's standing lease accounts for this bound.
+
+The cursor is sealed with purpose `directory.roster-cursor/<directory origin>`.
+Its closed JSON binds consumer, team, generation, epoch, fetch challenge,
+last opaque subject, next index, issuance and expiry. It expires at ten seconds,
+including at the exact boundary. Wrong scope/challenge, malformed, expired or
+unauthentic cursors return fixed 400. A scoped valid cursor with changed
+generation/epoch returns the signed 409 shape with current values. Rotation and
+disable are checked again before cursor processing. No cursor grants authority.
diff --git a/internal/home/directorydata/roster.go b/internal/home/directorydata/roster.go
new file mode 100644
index 0000000..317cd2c
--- /dev/null
+++ b/internal/home/directorydata/roster.go
@@ -0,0 +1,194 @@
+package directorydata
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "errors"
+ "io/fs"
+ "time"
+
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-json-experiment/json"
+)
+
+//go:embed roster_migrations/migrations/*.sql
+var rosterMigrations embed.FS
+
+// RosterSchema is directory-only and deliberately unmounted. Applying it leaves
+// generation and consumer authority empty. Apply it after Schema.
+var RosterSchema = func() *migrate.Set {
+ f, err := fs.Sub(rosterMigrations, "roster_migrations")
+ if err != nil {
+ panic(err)
+ }
+ return migrate.MustFromFS(f, "oficina_directory_roster")
+}()
+
+var (
+ ErrRosterUnauthorized = errors.New("roster unauthorized")
+ ErrRosterNotFound = errors.New("roster not found")
+ ErrRosterBadRequest = errors.New("invalid roster request")
+)
+
+type RosterSource struct {
+ store *Store
+ directory string
+}
+
+// NewRosterSource creates a read-only source. The caller authenticates operator
+// provisioning separately; this constructor never initializes authority.
+func NewRosterSource(db *sql.DB, sealer Sealer, cfg Config, directory string) (*RosterSource, error) {
+ if !validOrigin(directory) {
+ return nil, ErrInvalid
+ }
+ s, err := New(db, sealer, cfg)
+ if err != nil {
+ return nil, err
+ }
+ return &RosterSource{store: s, directory: directory}, nil
+}
+
+type RosterRequest struct{ Token, Team, Fetch, Cursor string }
+
+type rosterCursor struct {
+ Version int `json:"version"`
+ Consumer string `json:"consumer"`
+ Team string `json:"team"`
+ Generation string `json:"generation"`
+ Epoch int64 `json:"epoch"`
+ Fetch string `json:"fetch"`
+ Last string `json:"last"`
+ Next int `json:"next"`
+ Issued int64 `json:"issued"`
+ Expires int64 `json:"expires"`
+}
+
+func (s *RosterSource) cursorPurpose() string { return "directory.roster-cursor/" + s.directory }
+
+// Read observes authorization, generation, epoch and members in one transaction.
+// Its Page must be signed by roster.SignPage at the HTTP boundary. A 409 result
+// has no membership fields. No returned mapping grants item access.
+func (s *RosterSource) Read(ctx context.Context, req RosterRequest, now time.Time) (roster.Page, int, error) {
+ fail := func(status int, err error) (roster.Page, int, error) { return roster.Page{}, status, err }
+ if !roster.ValidChallenge(req.Token) {
+ return fail(401, ErrRosterUnauthorized)
+ }
+ if !alnum(req.Team, 64) || !roster.ValidChallenge(req.Fetch) || len(req.Cursor) > roster.CursorLimit || now.Unix() <= 0 {
+ return fail(400, ErrRosterBadRequest)
+ }
+ tx, err := s.store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ defer tx.Rollback()
+ hash := sha256.Sum256([]byte(req.Token))
+ var route Route
+ var enabled int
+ var state, slot string
+ var issued int64
+ var expires sql.NullInt64
+ err = tx.QueryRowContext(ctx, `SELECT c.consumer_id,c.team_id,c.app,c.origin,c.enabled,k.slot,k.issued_at,k.expires_at,t.sqid,t.label,i.state
+ FROM roster_tokens k JOIN roster_consumers c ON c.consumer_id=k.consumer_id
+ JOIN teams t ON t.id=c.team_id JOIN team_instances i ON i.team_id=c.team_id AND i.app=c.app AND i.consumer_id=c.consumer_id AND i.origin=c.origin
+ WHERE k.token_hash=?`, hash[:]).Scan(&route.ConsumerID, &route.TeamID, &route.App, &route.Origin, &enabled, &slot, &issued, &expires, &route.TeamSQID, &route.Label, &state)
+ if errors.Is(err, sql.ErrNoRows) {
+ return fail(401, ErrRosterUnauthorized)
+ }
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ if enabled != 1 || issued > now.Unix() || issued <= 0 || (slot != "current" && slot != "previous") || slot == "current" && expires.Valid || slot == "previous" && (!expires.Valid || expires.Int64 <= now.Unix() || expires.Int64 <= issued || expires.Int64-issued > 300) {
+ return fail(401, ErrRosterUnauthorized)
+ }
+ 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) {
+ 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)}
+ err = tx.QueryRowContext(ctx, `SELECT g.generation,t.roster_epoch FROM roster_generation g JOIN teams t ON t.id=? WHERE g.singleton=1`, route.TeamID).Scan(&p.Generation, &p.Epoch)
+ if err != nil || !roster.ValidChallenge(p.Generation) || p.Epoch <= 0 {
+ return fail(503, ErrUnavailable)
+ }
+ index := 0
+ last := ""
+ if req.Cursor != "" {
+ raw, e := base64.RawURLEncoding.Strict().DecodeString(req.Cursor)
+ if e != nil || base64.RawURLEncoding.EncodeToString(raw) != req.Cursor {
+ return fail(400, ErrRosterBadRequest)
+ }
+ plain, e := s.store.sealer.Open(s.cursorPurpose(), raw)
+ if e != nil {
+ return fail(400, ErrRosterBadRequest)
+ }
+ var c rosterCursor
+ if json.Unmarshal(plain, &c, json.RejectUnknownMembers(true)) != nil || c.Version != 1 || c.Consumer != route.ConsumerID || c.Team != route.TeamSQID || c.Fetch != req.Fetch || !validSubject(c.Last) || !roster.ValidChallenge(c.Generation) || c.Epoch <= 0 || c.Next < 1 || c.Next >= 100 || c.Issued <= 0 || c.Issued > now.Unix() || c.Expires <= now.Unix() || c.Expires <= c.Issued || c.Expires-c.Issued > 10 {
+ return fail(400, ErrRosterBadRequest)
+ }
+ if c.Generation != p.Generation || c.Epoch != p.Epoch {
+ p.Type = "stale-cursor"
+ if tx.Commit() != nil {
+ return fail(503, ErrUnavailable)
+ }
+ return p, 409, nil
+ }
+ index = c.Next
+ last = c.Last
+ }
+ var count int
+ if tx.QueryRowContext(ctx, `SELECT count(*) FROM (SELECT 1 FROM memberships WHERE team_id=? LIMIT 10001)`, route.TeamID).Scan(&count) != nil || count > roster.MemberLimit {
+ return fail(503, ErrUnavailable)
+ }
+ rows, err := tx.QueryContext(ctx, `SELECT person_ref,role FROM memberships WHERE team_id=? AND person_ref COLLATE BINARY > ? ORDER BY person_ref COLLATE BINARY LIMIT 101`, route.TeamID, last)
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ members := make([]roster.MemberEntry, 0, 101)
+ for rows.Next() {
+ var m roster.MemberEntry
+ if rows.Scan(&m.Subject, &m.Role) != nil || !validSubject(m.Subject) || (m.Role != "owner" && m.Role != "member") || m.Subject <= last {
+ rows.Close()
+ return fail(503, ErrUnavailable)
+ }
+ last = m.Subject
+ members = append(members, m)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ complete := len(members) <= roster.PageLimit
+ if !complete {
+ if index >= 99 {
+ return fail(503, ErrUnavailable)
+ }
+ members = members[:roster.PageLimit]
+ c := rosterCursor{Version: 1, Consumer: route.ConsumerID, Team: route.TeamSQID, Generation: p.Generation, Epoch: p.Epoch, Fetch: req.Fetch, Last: members[len(members)-1].Subject, Next: index + 1, Issued: now.Unix(), Expires: now.Unix() + 10}
+ b, e := json.Marshal(c)
+ if e != nil {
+ return fail(503, ErrUnavailable)
+ }
+ sealed, e := s.store.sealer.Seal(s.cursorPurpose(), b)
+ if e != nil {
+ return fail(503, ErrUnavailable)
+ }
+ cursor := base64.RawURLEncoding.EncodeToString(sealed)
+ if len(cursor) > roster.CursorLimit {
+ return fail(503, ErrUnavailable)
+ }
+ p.Cursor = &cursor
+ }
+ p.Index = &index
+ p.Members = &members
+ p.Complete = &complete
+ if tx.Commit() != nil {
+ return fail(503, ErrUnavailable)
+ }
+ return p, 200, nil
+}
diff --git a/internal/home/directorydata/roster_migrations/migrations/0001_roster.sql b/internal/home/directorydata/roster_migrations/migrations/0001_roster.sql
new file mode 100644
index 0000000..955f890
--- /dev/null
+++ b/internal/home/directorydata/roster_migrations/migrations/0001_roster.sql
@@ -0,0 +1,43 @@
+CREATE TABLE roster_generation (
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
+ generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*')
+);
+CREATE TABLE roster_consumers (
+ consumer_id TEXT PRIMARY KEY REFERENCES team_instances(consumer_id) ON DELETE RESTRICT,
+ team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,
+ app TEXT NOT NULL,
+ origin TEXT NOT NULL,
+ enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),
+ FOREIGN KEY(team_id,app) REFERENCES team_instances(team_id,app) ON DELETE RESTRICT
+);
+CREATE TABLE roster_tokens (
+ token_hash BLOB PRIMARY KEY CHECK(typeof(token_hash) = 'blob' AND length(token_hash) = 32),
+ consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id) ON DELETE RESTRICT,
+ slot TEXT NOT NULL CHECK(slot IN ('current','previous')),
+ issued_at INTEGER NOT NULL CHECK(typeof(issued_at) = 'integer' AND issued_at > 0),
+ expires_at INTEGER,
+ UNIQUE(consumer_id,slot),
+ CHECK((slot = 'current' AND expires_at IS NULL) OR
+ (slot = 'previous' AND typeof(expires_at) = 'integer' AND expires_at > issued_at AND expires_at - issued_at <= 300))
+);
+CREATE TABLE roster_operator_receipts (
+ operation_id TEXT PRIMARY KEY CHECK(length(operation_id) BETWEEN 1 AND 128 AND operation_id NOT GLOB '*[^a-zA-Z0-9_-]*'),
+ sequence INTEGER NOT NULL CHECK(typeof(sequence) = 'integer' AND sequence > 0),
+ operation TEXT NOT NULL CHECK(operation IN ('bootstrap','provision','rotate','disable','restore')),
+ consumer_id TEXT CHECK(consumer_id IS NULL OR (length(consumer_id) BETWEEN 1 AND 128 AND consumer_id NOT GLOB '*[^a-zA-Z0-9_-]*')),
+ generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*'),
+ record_digest BLOB NOT NULL CHECK(typeof(record_digest) = 'blob' AND length(record_digest) = 32),
+ completed_at INTEGER NOT NULL CHECK(typeof(completed_at) = 'integer' AND completed_at > 0)
+);
+CREATE TRIGGER memberships_epoch_insert AFTER INSERT ON memberships BEGIN
+ SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = NEW.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;
+ UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = NEW.team_id;
+END;
+CREATE TRIGGER memberships_epoch_delete AFTER DELETE ON memberships BEGIN
+ SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = OLD.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;
+ UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = OLD.team_id;
+END;
+CREATE TRIGGER memberships_epoch_update AFTER UPDATE ON memberships BEGIN
+ SELECT CASE WHEN EXISTS(SELECT 1 FROM teams WHERE id IN (OLD.team_id,NEW.team_id) AND roster_epoch = 9223372036854775807) THEN RAISE(ABORT,'roster epoch exhausted') END;
+ UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id IN (OLD.team_id,NEW.team_id);
+END;
diff --git a/internal/home/directorydata/roster_test.go b/internal/home/directorydata/roster_test.go
new file mode 100644
index 0000000..cd57d2e
--- /dev/null
+++ b/internal/home/directorydata/roster_test.go
@@ -0,0 +1,383 @@
+package directorydata
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base32"
+ "encoding/base64"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/sealtest"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-json-experiment/json"
+)
+
+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) {
+ t.Helper()
+ s, d, path := fixture(t)
+ if _, err := migrate.Apply(context.Background(), d, RosterSchema); 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")
+ instance(t, s, b, "beta", "docs", "live")
+ exec(t, s.db, `INSERT INTO roster_generation VALUES(1,?)`, challenge(3))
+ exec(t, s.db, `INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, a)
+ token := challenge(9)
+ h := sha256.Sum256([]byte(token))
+ exec(t, s.db, `INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, h[:], rosterNow.Unix()-1)
+ src, err := NewRosterSource(s.db, s.sealer, Config{Apps: []string{"docs"}, ValidateRoute: s.validateRoute}, "https://home.oficina.works")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return src, RosterRequest{Token: token, Team: "A1", Fetch: challenge(8)}, path, a
+}
+func addRoster(t *testing.T, s *RosterSource, team int64, n int) {
+ t.Helper()
+ tx, e := s.store.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ for i := 0; i < n; i++ {
+ raw := make([]byte, 16)
+ raw[0] = byte(i >> 8)
+ raw[1] = byte(i)
+ ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw))
+ if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, team, ref); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+}
+func TestRosterEmptyScopeRemovalPrivacy(t *testing.T) {
+ s, r, path, a := rosterFixture(t)
+ ctx := context.Background()
+ p, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || p.Members == nil || len(*p.Members) != 0 || p.Complete == nil || !*p.Complete || p.Cursor != nil || p.Consumer != "alpha-docs" || p.Team != "A1" || p.Audience != "https://alpha.docs.oficina.works" {
+ t.Fatal(p, status, e)
+ }
+ member(t, s.store, a, personA, "owner")
+ p, _, e = s.Read(ctx, r, rosterNow)
+ if e != nil || len(*p.Members) != 1 || (*p.Members)[0].Subject != personA {
+ t.Fatal(p, e)
+ }
+ epoch := p.Epoch
+ exec(t, s.store.db, `DELETE FROM memberships WHERE team_id=?`, a)
+ p, _, e = s.Read(ctx, r, rosterNow)
+ if e != nil || len(*p.Members) != 0 || p.Epoch != epoch+1 {
+ t.Fatal(p, e)
+ }
+ for _, team := range []string{"B2", "Missing"} {
+ bad := r
+ bad.Team = team
+ p, status, e = s.Read(ctx, bad, rosterNow)
+ if status != 404 || !errors.Is(e, ErrRosterNotFound) || p.Team != "" {
+ t.Fatal(p, status, e)
+ }
+ }
+ sealtest.AssertNoPlaintext(t, path)
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ b, e := os.ReadFile(path + suffix)
+ if e != nil && os.IsNotExist(e) {
+ continue
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ if bytes.Contains(b, []byte(r.Token)) {
+ t.Fatal("plaintext token at rest")
+ }
+ }
+}
+func TestRosterRotationDisableAndMissingGeneration(t *testing.T) {
+ s, r, _, _ := rosterFixture(t)
+ ctx := context.Background()
+ old := r
+ tx, e := s.store.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = tx.Exec(`UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=?`, rosterNow.Unix(), rosterNow.Unix()+300); e != nil {
+ t.Fatal(e)
+ }
+ r.Token = challenge(10)
+ h := sha256.Sum256([]byte(r.Token))
+ if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, h[:], rosterNow.Unix()); e != nil {
+ t.Fatal(e)
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ for _, req := range []RosterRequest{old, r} {
+ if _, status, e := s.Read(ctx, req, rosterNow); e != nil || status != 200 {
+ t.Fatal(status, e)
+ }
+ }
+ if _, status, e := s.Read(ctx, old, rosterNow.Add(300*time.Second)); status != 401 || e != ErrRosterUnauthorized {
+ t.Fatal(status, e)
+ }
+ if _, e = s.store.db.Exec(`UPDATE roster_tokens SET expires_at=issued_at+301 WHERE slot='previous'`); e == nil {
+ t.Fatal("unbounded overlap accepted")
+ }
+ exec(t, s.store.db, `UPDATE roster_consumers SET enabled=0`)
+ if _, status, e := s.Read(ctx, r, rosterNow); status != 401 || e != ErrRosterUnauthorized {
+ t.Fatal(status, e)
+ }
+ exec(t, s.store.db, `UPDATE roster_consumers SET enabled=1`)
+ exec(t, s.store.db, `DELETE FROM roster_generation`)
+ if _, status, e := s.Read(ctx, r, rosterNow); status != 503 || e != ErrUnavailable {
+ t.Fatal(status, e)
+ }
+}
+func TestRosterPagingSealedCursorAndEpoch(t *testing.T) {
+ s, r, path, a := rosterFixture(t)
+ addRoster(t, s, a, 201)
+ ctx := context.Background()
+ first, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || len(*first.Members) != 100 || *first.Complete || first.Cursor == nil {
+ t.Fatal(first, status, e)
+ }
+ cursor := *first.Cursor
+ raw, err := base64.RawURLEncoding.DecodeString(cursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, err := s.store.sealer.Open(s.cursorPurpose(), raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ b, e := os.ReadFile(path + suffix)
+ if os.IsNotExist(e) {
+ continue
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, secret := range [][]byte{[]byte(r.Token), []byte(cursor), plain} {
+ if bytes.Contains(b, secret) {
+ t.Fatal("roster request or cursor persisted")
+ }
+ }
+ }
+ sealtest.AssertNoPlaintext(t, path)
+ r.Cursor = cursor
+ second, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || *second.Index != 1 || len(*second.Members) != 100 || (*second.Members)[0].Subject <= (*first.Members)[99].Subject {
+ t.Fatal(second, status, e)
+ }
+ r.Cursor = *second.Cursor
+ third, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || len(*third.Members) != 1 || !*third.Complete || third.Cursor != nil {
+ t.Fatal(third, status, e)
+ }
+ r.Cursor = cursor
+ for _, change := range []func(*RosterRequest){func(r *RosterRequest) { r.Fetch = challenge(99) }, func(r *RosterRequest) { r.Cursor = r.Cursor[:len(r.Cursor)-1] + "!" }, func(r *RosterRequest) { r.Cursor += "=" }} {
+ bad := r
+ change(&bad)
+ if _, status, e = s.Read(ctx, bad, rosterNow); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+ }
+ if _, status, e = s.Read(ctx, r, rosterNow.Add(10*time.Second)); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+ exec(t, s.store.db, `UPDATE memberships SET role='owner' WHERE team_id=? AND person_ref=?`, a, personA)
+ p, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 409 || p.Type != "stale-cursor" || p.Index != nil || p.Members != nil || p.Complete != nil || p.Cursor != nil || p.Epoch != first.Epoch+1 {
+ t.Fatal(p, status, e)
+ }
+ exec(t, s.store.db, `UPDATE roster_generation SET generation=?`, challenge(4))
+ p, status, e = s.Read(ctx, r, rosterNow)
+ if e != nil || status != 409 || p.Generation != challenge(4) {
+ t.Fatal(p, status, e)
+ }
+}
+func TestRosterCursorStrictFieldsAndPurpose(t *testing.T) {
+ s, r, _, a := rosterFixture(t)
+ addRoster(t, s, a, 101)
+ p, _, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ b, _ := base64.RawURLEncoding.DecodeString(*p.Cursor)
+ plain, e := s.store.sealer.Open(s.cursorPurpose(), b)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, body := range []string{strings.TrimSuffix(string(plain), "}") + `,"unknown":1}`, strings.TrimSuffix(string(plain), "}") + `,"version":1}`} {
+ sealed, e := s.store.sealer.Seal(s.cursorPurpose(), []byte(body))
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Cursor = base64.RawURLEncoding.EncodeToString(sealed)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+ }
+ var c rosterCursor
+ if json.Unmarshal(plain, &c) != nil {
+ t.Fatal("cursor decode")
+ }
+ if c.Expires-c.Issued != 10 {
+ t.Fatal(c)
+ }
+ sealed, e := s.store.sealer.Seal("directory.roster-cursor/https://other.oficina.works", plain)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Cursor = base64.RawURLEncoding.EncodeToString(sealed)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+}
+func TestRosterEpochOverflowAtomicAndOtherTeamIsolation(t *testing.T) {
+ s, _, _, a := rosterFixture(t)
+ member(t, s.store, a, personA, "owner")
+ var before int64
+ if e := s.store.db.QueryRow(`SELECT roster_epoch FROM teams WHERE sqid='B2'`).Scan(&before); e != nil {
+ t.Fatal(e)
+ }
+ exec(t, s.store.db, `UPDATE memberships SET role='member' WHERE team_id=?`, a)
+ var after int64
+ s.store.db.QueryRow(`SELECT roster_epoch FROM teams WHERE sqid='B2'`).Scan(&after)
+ if after != before {
+ t.Fatal("foreign epoch changed")
+ }
+ exec(t, s.store.db, `UPDATE teams SET roster_epoch=9223372036854775807 WHERE id=?`, a)
+ for _, q := range []string{`DELETE FROM memberships WHERE team_id=?`, `UPDATE memberships SET role='owner' WHERE team_id=?`, `INSERT INTO memberships(team_id,person_ref,role) VALUES(?,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')`} {
+ if _, e := s.store.db.Exec(q, a); e == nil {
+ t.Fatal("overflow accepted")
+ }
+ }
+ var count int
+ s.store.db.QueryRow(`SELECT count(*) FROM memberships WHERE team_id=?`, a).Scan(&count)
+ if count != 1 {
+ t.Fatal(count)
+ }
+}
+func TestRosterBoundAndInvalidLookahead(t *testing.T) {
+ s, r, _, a := rosterFixture(t)
+ addRoster(t, s, a, 10001)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 503 || e != ErrUnavailable {
+ t.Fatal(status, e)
+ }
+ exec(t, s.store.db, `DELETE FROM memberships WHERE person_ref=(SELECT max(person_ref) FROM memberships WHERE team_id=?) AND team_id=?`, a, a)
+ p, status, e := s.Read(context.Background(), r, rosterNow)
+ if status != 200 || e != nil || len(*p.Members) != 100 {
+ t.Fatal(p, status, e)
+ }
+ exec(t, s.store.db, `DELETE FROM memberships`)
+ addRoster(t, s, a, 100)
+ exec(t, s.store.db, `PRAGMA ignore_check_constraints=ON`)
+ exec(t, s.store.db, `INSERT INTO memberships VALUES(?,'zz-invalid','owner')`, a)
+ exec(t, s.store.db, `PRAGMA ignore_check_constraints=OFF`)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 503 || e != ErrUnavailable {
+ t.Fatal(status, e)
+ }
+}
+
+type onOpenSealer struct {
+ Sealer
+ once func()
+}
+
+func (s *onOpenSealer) Open(p string, b []byte) ([]byte, error) {
+ if s.once != nil {
+ f := s.once
+ s.once = nil
+ f()
+ }
+ return s.Sealer.Open(p, b)
+}
+func TestRosterOneReadSnapshotUnderConcurrentRevocation(t *testing.T) {
+ s, r, path, a := rosterFixture(t)
+ addRoster(t, s, a, 201)
+ p, _, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ 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() {
+ tx, e := writer.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, a); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = tx.Exec(`UPDATE roster_consumers SET enabled=0`); e != nil {
+ t.Fatal(e)
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ }}
+ second, status, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil || status != 200 || second.Epoch != p.Epoch || len(*second.Members) != 100 {
+ t.Fatal(second, status, e)
+ }
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 401 || e != ErrRosterUnauthorized {
+ t.Fatal(status, e)
+ }
+}
+func TestRosterMigrationLeavesAuthorityEmpty(t *testing.T) {
+ _, d, _ := fixture(t)
+ if _, e := migrate.Apply(context.Background(), d, RosterSchema); e != nil {
+ t.Fatal(e)
+ }
+ for _, table := range []string{"roster_generation", "roster_consumers", "roster_tokens", "roster_operator_receipts"} {
+ var n int
+ if e := d.Writer().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); e != nil || n != 0 {
+ t.Fatal(table, n, e)
+ }
+ }
+}
+
+func TestRosterInputAndConfigurationRefusal(t *testing.T) {
+ s, r, _, _ := rosterFixture(t)
+ for _, tc := range []struct {
+ change func(*RosterRequest)
+ status int
+ }{
+ {func(r *RosterRequest) { r.Token = "secret@example.invalid" }, 401},
+ {func(r *RosterRequest) { r.Token = challenge(55) }, 401},
+ {func(r *RosterRequest) { r.Team = "../B2" }, 400},
+ {func(r *RosterRequest) { r.Fetch = "canary@example.invalid" }, 400},
+ {func(r *RosterRequest) { r.Cursor = strings.Repeat("a", 2049) }, 400},
+ } {
+ bad := r
+ tc.change(&bad)
+ p, status, e := s.Read(context.Background(), bad, rosterNow)
+ if status != tc.status || e == nil || p.Team != "" || strings.Contains(e.Error(), "@") || strings.Contains(e.Error(), bad.Token) {
+ t.Fatal(p, status, e)
+ }
+ }
+ for _, q := range []string{`UPDATE team_instances SET state='pending'`, `UPDATE team_instances SET state='disabled'`, `UPDATE roster_consumers SET origin='https://foreign.oficina.works'`} {
+ exec(t, s.store.db, q)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); e == nil || status != 503 && status != 401 {
+ t.Fatal(status, e)
+ }
+ }
+}