Add unmounted directory roster snapshot storage
6 files changed,
+699
−7
go.mod+1 −1go.sum+2 −6implementation/roster-producer-storage.md+76 −0internal/home/directorydata/roster.go+194 −0internal/home/directorydata/roster_migrations/migrations/0001_roster.sql+43 −0internal/home/directorydata/roster_test.go+383 −0
CI failed — run details
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) |
| + } |
| + } |
| +} |