Add unmounted principal ledger with explicit membership creation seam
7 files changed,
+612
−0
internal/docs/principalstore/migrations/0001_principals.sql+47 −0internal/docs/principalstore/store.go+208 −0internal/docs/principalstore/store_test.go+245 −0internal/docs/principalstore/testdata/compat_main.go.txt+44 −0internal/docs/principalstore/testdata/compat_new.go.txt+8 −0internal/docs/principalstore/testdata/compat_old.go.txt+3 −0scripts/check-principal-compat.py+57 −0
diff --git a/internal/docs/principalstore/migrations/0001_principals.sql b/internal/docs/principalstore/migrations/0001_principals.sql| new file mode 100644 |
| index 0000000..70a90fd |
| --- /dev/null |
| +++ b/internal/docs/principalstore/migrations/0001_principals.sql |
| @@ -0,0 +1,47 @@ |
| +CREATE TABLE docs_principal_preflight (ok INTEGER CHECK(ok=1)); |
| +INSERT INTO docs_principal_preflight SELECT CASE WHEN |
| + NOT EXISTS(SELECT 1 FROM people WHERE id <= 0 OR id >= 4503599627370496) |
| + AND NOT EXISTS(SELECT 1 FROM documents d WHERE d.owner_id < 0 OR (d.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM people p WHERE p.id=d.owner_id))) |
| + AND NOT EXISTS(SELECT 1 FROM grants g WHERE g.created_by < 0 OR (g.created_by > 0 AND NOT EXISTS(SELECT 1 FROM people p WHERE p.id=g.created_by))) |
| + AND NOT EXISTS(SELECT 1 FROM pragma_foreign_key_check) |
| + AND (SELECT foreign_keys FROM pragma_foreign_keys)=1 |
| + THEN 1 ELSE 0 END; |
| +DROP TABLE docs_principal_preflight; |
| +CREATE TABLE docs_principals ( |
| + id INTEGER PRIMARY KEY, |
| + kind TEXT NOT NULL CHECK((kind='local' AND id BETWEEN 1 AND 4503599627370495) OR (kind='home' AND id BETWEEN 4503599627370496 AND 9007199254740991)), |
| + created_at DATETIME NOT NULL |
| +); |
| +INSERT INTO docs_principals SELECT id,'local',created_at FROM people; |
| +CREATE TABLE docs_home_identities ( |
| + issuer TEXT NOT NULL, |
| + subject TEXT NOT NULL, |
| + principal_id INTEGER NOT NULL UNIQUE REFERENCES docs_principals(id), |
| + PRIMARY KEY(issuer,subject) |
| +); |
| +CREATE TABLE docs_home_allocator ( |
| + singleton INTEGER PRIMARY KEY CHECK(singleton=1), |
| + high_water INTEGER NOT NULL CHECK(typeof(high_water)='integer' AND high_water BETWEEN 4503599627370495 AND 9007199254740991) |
| +); |
| +INSERT INTO docs_home_allocator VALUES(1,4503599627370495); |
| +CREATE TRIGGER docs_people_mirror AFTER INSERT ON people BEGIN |
| + SELECT CASE WHEN NEW.id < 1 OR NEW.id >= 4503599627370496 THEN RAISE(ABORT,'docs principal: refused') END; |
| + INSERT INTO docs_principals VALUES(NEW.id,'local',NEW.created_at); |
| +END; |
| +CREATE TRIGGER docs_people_keep_id BEFORE UPDATE OF id ON people WHEN NEW.id != OLD.id BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_people_keep_row BEFORE DELETE ON people BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_principals_keep_identity BEFORE UPDATE OF id,kind ON docs_principals WHEN NEW.id != OLD.id OR NEW.kind != OLD.kind BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_principals_keep_row BEFORE DELETE ON docs_principals BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_allocator_monotonic BEFORE UPDATE ON docs_home_allocator WHEN NEW.singleton != OLD.singleton OR NEW.high_water <= OLD.high_water BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_allocator_keep_row BEFORE DELETE ON docs_home_allocator BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identity_kind BEFORE INSERT ON docs_home_identities WHEN NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.principal_id AND kind='home') BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identity_unique BEFORE INSERT ON docs_home_identities WHEN EXISTS(SELECT 1 FROM docs_identity_links WHERE issuer=NEW.issuer AND subject=NEW.subject) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_local_identity_unique BEFORE INSERT ON docs_identity_links WHEN EXISTS(SELECT 1 FROM docs_home_identities WHERE issuer=NEW.issuer AND subject=NEW.subject) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identities_keep_update BEFORE UPDATE ON docs_home_identities BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_home_identities_keep_delete BEFORE DELETE ON docs_home_identities BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_identity_links_keep_update BEFORE UPDATE ON docs_identity_links BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_identity_links_keep_delete BEFORE DELETE ON docs_identity_links BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_documents_principal_insert BEFORE INSERT ON documents WHEN NEW.owner_id < 0 OR (NEW.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.owner_id)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_documents_principal_update BEFORE UPDATE OF owner_id ON documents WHEN NEW.owner_id < 0 OR (NEW.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.owner_id)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_grants_principal_insert BEFORE INSERT ON grants WHEN NEW.created_by < 0 OR (NEW.created_by > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.created_by)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
| +CREATE TRIGGER docs_grants_principal_update BEFORE UPDATE OF created_by ON grants WHEN NEW.created_by < 0 OR (NEW.created_by > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.created_by)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END; |
diff --git a/internal/docs/principalstore/store.go b/internal/docs/principalstore/store.go| new file mode 100644 |
| index 0000000..b9d727d |
| --- /dev/null |
| +++ b/internal/docs/principalstore/store.go |
| @@ -0,0 +1,208 @@ |
| +// Package principalstore allocates identity records only. A principal is not |
| +// membership, an authenticated session, or permission to read a document. |
| +package principalstore |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "embed" |
| + "errors" |
| + "net/url" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| + |
| +// Schema follows Docs and linkstore schemas. It remains unmounted until the |
| +// combined migration, admission and rollback release has been reviewed. |
| +var Schema = migrate.MustFromFS(migrations, "docs_principal") |
| + |
| +const HomeIDMin int64 = 1 << 52 |
| +const HomeIDMax int64 = 1<<53 - 1 |
| + |
| +var ( |
| + ErrRefused = errors.New("docs principal: refused") |
| + ErrUnavailable = errors.New("docs principal: unavailable") |
| +) |
| + |
| +type Kind string |
| + |
| +const ( |
| + Local Kind = "local" |
| + Home Kind = "home" |
| +) |
| + |
| +type Principal struct { |
| + ID int64 |
| + Kind Kind |
| + // Nil means no verified local address. Never substitute an empty or invented |
| + // address in allowlists, sample creation or grant equality queries. |
| + VerifiedAddress *string |
| +} |
| + |
| +// HomeIdentity is supplied only after shared handoff verification and replay |
| +// consumption. The store cannot authenticate a browser or an assertion. |
| +type HomeIdentity struct{ Issuer, Subject string } |
| +type MembershipRequest struct{ Issuer, Team, Subject string } |
| +type Config struct { |
| + Issuer, Team string |
| + // RequireMembership consults the authoritative roster cache outside the SQL |
| + // transaction. Missing/expired/untrusted snapshot is ErrUnavailable; a fresh |
| + // complete snapshot lacking the person is ErrRefused. Success is a bounded |
| + // observation, not a durable grant. Each later access must recheck authority. |
| + RequireMembership func(context.Context, MembershipRequest) error |
| +} |
| +type Store struct { |
| + db *sql.DB |
| + cfg Config |
| +} |
| + |
| +func New(db *sql.DB, cfg Config) (*Store, error) { |
| + if db == nil || !origin(cfg.Issuer) || !opaque(cfg.Team, 64) { |
| + return nil, ErrRefused |
| + } |
| + return &Store{db: db, cfg: cfg}, nil |
| +} |
| + |
| +func (s *Store) Person(ctx context.Context, id int64) (Principal, error) { |
| + if id <= 0 || id > HomeIDMax { |
| + return Principal{}, ErrRefused |
| + } |
| + return readPerson(s.db.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id)) |
| +} |
| +func readPerson(row *sql.Row) (Principal, error) { |
| + var p Principal |
| + var address sql.NullString |
| + err := row.Scan(&p.ID, &p.Kind, &address) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return Principal{}, ErrRefused |
| + } |
| + if err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + switch p.Kind { |
| + case Local: |
| + if p.ID <= 0 || p.ID >= HomeIDMin || !address.Valid { |
| + return Principal{}, ErrRefused |
| + } |
| + a := strings.ToLower(strings.TrimSpace(address.String)) |
| + if a == "" { |
| + return Principal{}, ErrRefused |
| + } |
| + p.VerifiedAddress = &a |
| + case Home: |
| + if p.ID < HomeIDMin || p.ID > HomeIDMax || address.Valid { |
| + return Principal{}, ErrRefused |
| + } |
| + default: |
| + return Principal{}, ErrRefused |
| + } |
| + return p, nil |
| +} |
| + |
| +// Resolve reads either an existing local link or a Home-only identity. It does |
| +// not create a record and returns no standing from a stored mapping. |
| +func (s *Store) Resolve(ctx context.Context, issuer, subject string) (Principal, error) { |
| + if issuer != s.cfg.Issuer || !opaque(subject, 512) { |
| + return Principal{}, ErrRefused |
| + } |
| + var id int64 |
| + var count int |
| + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MIN(id),0),COUNT(*) FROM (SELECT person_id AS id FROM docs_identity_links WHERE issuer=? AND subject=? UNION ALL SELECT principal_id AS id FROM docs_home_identities WHERE issuer=? AND subject=?)`, issuer, subject, issuer, subject).Scan(&id, &count) |
| + if err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if count != 1 { |
| + return Principal{}, ErrRefused |
| + } |
| + return s.Person(ctx, id) |
| +} |
| + |
| +// EnsureHome requires a current membership check even if another call has |
| +// already created this Home-only identity. Use Resolve for identity-only reads. |
| +func (s *Store) EnsureHome(ctx context.Context, identity HomeIdentity) (Principal, error) { |
| + if identity.Issuer != s.cfg.Issuer || !opaque(identity.Subject, 512) || s.cfg.RequireMembership == nil { |
| + return Principal{}, ErrRefused |
| + } |
| + if err := s.cfg.RequireMembership(ctx, MembershipRequest{identity.Issuer, s.cfg.Team, identity.Subject}); err != nil { |
| + if errors.Is(err, ErrRefused) { |
| + return Principal{}, ErrRefused |
| + } |
| + return Principal{}, ErrUnavailable |
| + } |
| + tx, err := s.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + // Take the writer lock before reading either mapping. The no-op write leaves |
| + // the allocator untouched and makes concurrent first arrivals one winner. |
| + if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=high_water WHERE singleton=0`); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + var local int |
| + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM docs_identity_links WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&local); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if local != 0 { |
| + return Principal{}, ErrRefused |
| + } |
| + var id int64 |
| + err = tx.QueryRowContext(ctx, `SELECT principal_id FROM docs_home_identities WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&id) |
| + if err == nil { |
| + p, err := readPerson(tx.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id)) |
| + if err != nil { |
| + return Principal{}, err |
| + } |
| + if p.Kind != Home { |
| + return Principal{}, ErrRefused |
| + } |
| + if tx.Commit() != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + return p, nil |
| + } |
| + if !errors.Is(err, sql.ErrNoRows) { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if err = tx.QueryRowContext(ctx, `SELECT high_water FROM docs_home_allocator WHERE singleton=1`).Scan(&id); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if id < HomeIDMin-1 || id >= HomeIDMax { |
| + return Principal{}, ErrRefused |
| + } |
| + id++ |
| + if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=? WHERE singleton=1`, id); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if _, err = tx.ExecContext(ctx, `INSERT INTO docs_principals(id,kind,created_at) VALUES(?,'home',?)`, id, time.Now().UTC()); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if _, err = tx.ExecContext(ctx, `INSERT INTO docs_home_identities(issuer,subject,principal_id) VALUES(?,?,?)`, identity.Issuer, identity.Subject, id); err != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + if tx.Commit() != nil { |
| + return Principal{}, ErrUnavailable |
| + } |
| + return Principal{ID: id, Kind: Home}, nil |
| +} |
| +func opaque(s string, max int) bool { |
| + if s == "" || len(s) > max || !utf8.ValidString(s) { |
| + return false |
| + } |
| + for _, r := range s { |
| + if r <= 32 || r == 127 || r == '@' { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func origin(s string) bool { |
| + u, e := url.Parse(s) |
| + return e == nil && len(s) <= 2048 && u.Scheme == "https" && u.Host != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == s && !strings.ContainsAny(s, "\\\r\n\t ") |
| +} |
diff --git a/internal/docs/principalstore/store_test.go b/internal/docs/principalstore/store_test.go| new file mode 100644 |
| index 0000000..715f234 |
| --- /dev/null |
| +++ b/internal/docs/principalstore/store_test.go |
| @@ -0,0 +1,245 @@ |
| +package principalstore_test |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + |
| + "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" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +const issuer = "https://id.oficina.works" |
| + |
| +func fixture(t *testing.T, apply bool) (*db.DB, *p.Store) { |
| + t.Helper() |
| + d, e := db.Open(filepath.Join(t.TempDir(), "docs.db"), nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, e = migrate.Apply(context.Background(), d, migrate.Merge(docs.Schema, linkstore.Schema)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if apply { |
| + if _, e = migrate.Apply(context.Background(), d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + s, e := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1", RequireMembership: func(_ context.Context, r p.MembershipRequest) error { |
| + if r.Issuer != issuer || r.Team != "A1" || r.Subject == "absent" { |
| + return p.ErrRefused |
| + } |
| + return nil |
| + }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return d, s |
| +} |
| +func exec(t *testing.T, d *db.DB, q string, args ...any) { |
| + t.Helper() |
| + if _, e := d.Writer().Exec(q, args...); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func local(t *testing.T, d *db.DB, address string) int64 { |
| + t.Helper() |
| + r, e := d.Writer().Exec(`INSERT INTO people(email,created_at)VALUES(?,CURRENT_TIMESTAMP)`, address) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + id, e := r.LastInsertId() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return id |
| +} |
| +func TestAllocationIsolationAddressAbsenceAndNoSamples(t *testing.T) { |
| + d, s := fixture(t, false) |
| + a := local(t, d, "alice@example.invalid") |
| + exec(t, d, `INSERT INTO docs_identity_links VALUES(?,?,?)`, issuer, "alice", a) |
| + if _, e := migrate.Apply(context.Background(), d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + old, e := s.Resolve(context.Background(), issuer, "alice") |
| + if e != nil || old.ID != a || old.Kind != p.Local || old.VerifiedAddress == nil || *old.VerifiedAddress != "alice@example.invalid" { |
| + t.Fatal(old, e) |
| + } |
| + h, e := s.EnsureHome(context.Background(), p.HomeIdentity{Issuer: issuer, Subject: "new-home"}) |
| + if e != nil || h.ID != p.HomeIDMin || h.Kind != p.Home || h.VerifiedAddress != nil { |
| + t.Fatal(h, e) |
| + } |
| + b := local(t, d, "bob@example.invalid") |
| + if b != a+1 { |
| + t.Fatal("trigger changed LastInsertId", a, b) |
| + } |
| + got, e := s.Person(context.Background(), b) |
| + if e != nil || got.ID != b || got.Kind != p.Local { |
| + t.Fatal(got, e) |
| + } |
| + resolved, e := s.Resolve(context.Background(), issuer, "new-home") |
| + if e != nil || resolved.ID != h.ID || resolved.VerifiedAddress != nil { |
| + t.Fatal(resolved, e) |
| + } |
| + for _, table := range []string{"documents", "grants"} { |
| + var n int |
| + if e = d.Writer().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); e != nil || n != 0 { |
| + t.Fatal(table, n, e) |
| + } |
| + } |
| + if _, e = s.EnsureHome(context.Background(), p.HomeIdentity{Issuer: issuer, Subject: "alice"}); !errors.Is(e, p.ErrRefused) { |
| + t.Fatal("merged local account", e) |
| + } |
| + if _, e = d.Writer().Exec(`INSERT INTO docs_identity_links VALUES(?,?,?)`, issuer, "new-home", b); e == nil { |
| + t.Fatal("cross-table collision accepted") |
| + } |
| +} |
| +func TestMembershipSeamAndIdentityOnlyResolve(t *testing.T) { |
| + d, s := fixture(t, true) |
| + ctx := context.Background() |
| + for _, failure := range []error{p.ErrRefused, p.ErrUnavailable, errors.New("secret diagnostic")} { |
| + cfg := p.Config{Issuer: issuer, Team: "A1", RequireMembership: func(context.Context, p.MembershipRequest) error { return failure }} |
| + blocked, e := p.New(d.Writer(), cfg) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + _, e = blocked.EnsureHome(ctx, p.HomeIdentity{Issuer: issuer, Subject: "new"}) |
| + want := p.ErrUnavailable |
| + if failure == p.ErrRefused { |
| + want = p.ErrRefused |
| + } |
| + if e != want { |
| + t.Fatal(e) |
| + } |
| + } |
| + none, _ := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1"}) |
| + if _, e := none.EnsureHome(ctx, p.HomeIdentity{issuer, "new"}); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| + var n int |
| + d.Writer().QueryRow(`SELECT COUNT(*) FROM docs_principals`).Scan(&n) |
| + if n != 0 { |
| + t.Fatal("unapproved principal", n) |
| + } |
| + h, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "member"}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + got, e := none.Resolve(ctx, issuer, "member") |
| + if e != nil || got.ID != h.ID { |
| + t.Fatal(got, e) |
| + } |
| + for _, id := range []p.HomeIdentity{{"https://foreign.example", "member"}, {issuer, "mail@example.invalid"}, {issuer, ""}} { |
| + if _, e = s.EnsureHome(ctx, id); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| + } |
| +} |
| +func TestConcurrentCreationAndImmutableIDs(t *testing.T) { |
| + d, s := fixture(t, true) |
| + ctx := context.Background() |
| + var wg sync.WaitGroup |
| + ids := make(chan int64, 16) |
| + errs := make(chan error, 16) |
| + for range 16 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + v, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "same"}) |
| + if e != nil { |
| + errs <- e |
| + } else { |
| + ids <- v.ID |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + close(ids) |
| + close(errs) |
| + for e := range errs { |
| + t.Fatal(e) |
| + } |
| + for id := range ids { |
| + if id != p.HomeIDMin { |
| + t.Fatal(id) |
| + } |
| + } |
| + a := local(t, d, "local@example.invalid") |
| + for _, q := range []string{ |
| + `DELETE FROM people`, `UPDATE people SET id=id+1`, `DELETE FROM docs_principals`, `UPDATE docs_principals SET kind='local' WHERE kind='home'`, |
| + `DELETE FROM docs_home_identities`, `UPDATE docs_home_identities SET subject='other'`, `UPDATE docs_home_allocator SET high_water=high_water-1`, |
| + fmt.Sprintf(`INSERT INTO people(id,email,created_at) VALUES(%d,'collision@example.invalid',CURRENT_TIMESTAMP)`, p.HomeIDMin), |
| + fmt.Sprintf(`INSERT INTO docs_home_identities VALUES('%s','wrong-kind',%d)`, issuer, a), |
| + `INSERT INTO docs_identity_links VALUES('https://id.oficina.works','missing',12345)`, |
| + } { |
| + if _, e := d.Writer().Exec(q); e == nil { |
| + t.Fatalf("accepted mutation: %s", q) |
| + } |
| + } |
| + if _, e := migrate.Apply(ctx, d, p.Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := migrate.Apply(ctx, d, migrate.Merge(docs.Schema, linkstore.Schema)); e != nil { |
| + t.Fatal("older known migration set rejected newer ledger", e) |
| + } |
| + rows, e := d.Writer().Query(`PRAGMA foreign_key_check`) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer rows.Close() |
| + if rows.Next() { |
| + t.Fatal("foreign key damage") |
| + } |
| +} |
| +func TestSentinelsReferencesAndExhaustion(t *testing.T) { |
| + d, s := fixture(t, true) |
| + ctx := context.Background() |
| + h, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "owner"}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for i, owner := range []int64{0, h.ID} { |
| + exec(t, d, `INSERT INTO documents(id,sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES(?,?,?,?,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)`, i+1, fmt.Sprint(i), "Title", "guid", owner) |
| + } |
| + exec(t, d, `INSERT INTO grants(doc_id,subject,role,created_by)VALUES(1,'reader@example.invalid','reader',0)`) |
| + exec(t, d, `UPDATE documents SET owner_id=owner_id,title=title`) |
| + exec(t, d, `UPDATE grants SET created_by=created_by`) |
| + for _, q := range []string{`UPDATE documents SET owner_id=1`, `UPDATE grants SET created_by=1`, `UPDATE documents SET owner_id=-1`} { |
| + if _, e = d.Writer().Exec(q); e == nil { |
| + t.Fatal(q) |
| + } |
| + } |
| + exec(t, d, `UPDATE docs_home_allocator SET high_water=?`, p.HomeIDMax) |
| + if _, e = s.EnsureHome(ctx, p.HomeIdentity{issuer, "past-cap"}); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| + if _, e = s.Resolve(ctx, issuer, "past-cap"); e != p.ErrRefused { |
| + t.Fatal(e) |
| + } |
| +} |
| +func TestPreflightIsAtomicAndPreservesExistingSchema(t *testing.T) { |
| + for _, q := range []string{ |
| + fmt.Sprintf(`INSERT INTO people VALUES(%d,'range@example.invalid',CURRENT_TIMESTAMP)`, p.HomeIDMin), |
| + `INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES('x','Title','g',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,12)`, |
| + `INSERT INTO grants(doc_id,subject,role,created_by)VALUES(1,'reader@example.invalid','reader',12)`, |
| + } { |
| + t.Run(q, func(t *testing.T) { |
| + d, _ := fixture(t, false) |
| + exec(t, d, q) |
| + if _, e := migrate.Apply(context.Background(), d, p.Schema); e == nil { |
| + t.Fatal("bad preflight accepted") |
| + } |
| + var n int |
| + if e := d.Writer().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'docs_principal%' OR name LIKE 'docs_home_%'`).Scan(&n); e != nil || n != 0 { |
| + t.Fatal("partial migration", n, e) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_main.go.txt b/internal/docs/principalstore/testdata/compat_main.go.txt| new file mode 100644 |
| index 0000000..f973ce8 |
| --- /dev/null |
| +++ b/internal/docs/principalstore/testdata/compat_main.go.txt |
| @@ -0,0 +1,44 @@ |
| +package main |
| +import( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "log/slog" |
| + "os" |
| + "io" |
| + "github.com/carlosframework/docs/internal/docs" |
| + "github.com/carlosframework/docs/internal/core" |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| +func must(e error){if e!=nil{panic(e)}} |
| +func main(){ |
| + path:=flag.String("db","","disposable database");address:=flag.String("address","","synthetic local account");phase:=flag.String("phase","","phase");flag.Parse() |
| + ctx:=context.Background();app,e:=docs.New(docs.Config{DBPath:*path,Env:"development",Origin:"https://compat.example",SampleDocuments:"false",Logger:slog.New(slog.NewTextHandler(io.Discard,nil))});must(e);defer app.Close() |
| + d,e:=db.Open(*path,nil);must(e);defer d.Close() |
| + id,e:=app.Store().EnsurePerson(ctx,*address);must(e) |
| + principal:=principalStep(ctx,d,*phase) |
| + if principal>0 {var n int;must(d.Writer().QueryRow("SELECT COUNT(*) FROM documents WHERE owner_id=?",principal).Scan(&n));if n==0{_,e:=app.Store().Create(ctx,principal,"home-owned");must(e)}} |
| + var sqid string;e=d.Writer().QueryRow(`SELECT sqid FROM documents WHERE title='kept-content'`).Scan(&sqid) |
| + if e!=nil{ |
| + doc,e:=app.Store().Create(ctx,id,"kept-content");must(e);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);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);must(e) |
| + _,e=d.Writer().Exec(`INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES('orphan','orphan','orphan',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)`);must(e) |
| + _,e=d.Writer().Exec(`INSERT INTO grants(doc_id,subject,role,created_by)VALUES(0,'sentinel@example.invalid','reader',0)`);must(e) |
| + } |
| + var owner int64;must(d.Writer().QueryRow(`SELECT owner_id FROM documents WHERE sqid=?`,sqid).Scan(&owner)) |
| + doc,e:=app.Store().Get(ctx,owner,sqid);must(e);text,e:=app.Store().Text(ctx,doc);must(e);if text!="hello world"{panic(text)} |
| + if _,e=d.Writer().Exec(`UPDATE documents SET owner_id=owner_id`);e!=nil{panic(e)} |
| + if _,e=d.Writer().Exec(`UPDATE grants SET created_by=created_by`);e!=nil{panic(e)} |
| + rows,e:=d.Writer().Query(`SELECT update_bin,content_encoding FROM doc_updates WHERE doc_id=(SELECT id FROM documents WHERE title='kept-content') UNION ALL SELECT state,content_encoding FROM doc_snapshots WHERE doc_id=(SELECT id FROM documents WHERE title='kept-content')`);must(e);h:=sha256.New();encodings:=[]int{};for rows.Next(){var b []byte;var encoding int;must(rows.Scan(&b,&encoding));h.Write(b);encodings=append(encodings,encoding)};must(rows.Err());rows.Close() |
| + var author int64;must(d.Writer().QueryRow(`SELECT created_by FROM grants WHERE subject='reader@example.invalid'`).Scan(&author)) |
| + var homeCount int;must(d.Writer().QueryRow(`SELECT COUNT(*) FROM documents WHERE owner_id>=4503599627370496`).Scan(&homeCount)) |
| + var ledger int;d.Writer().QueryRow(`SELECT COUNT(*) FROM docs_principals`).Scan(&ledger) |
| + out:=map[string]any{"phase":*phase,"local_id":id,"home_id":principal,"owner":owner,"grant_author":author,"sqid":sqid,"content_hash":hex.EncodeToString(h.Sum(nil)),"encodings":encodings,"text":text,"principal_count":ledger,"home_owned":homeCount} |
| + b,e:=json.Marshal(out);must(e);fmt.Println(string(b)) |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_new.go.txt b/internal/docs/principalstore/testdata/compat_new.go.txt| new file mode 100644 |
| index 0000000..23a23be |
| --- /dev/null |
| +++ b/internal/docs/principalstore/testdata/compat_new.go.txt |
| @@ -0,0 +1,8 @@ |
| +package main |
| +import("context";"github.com/carlosframework/rastrillo/db";"github.com/carlosframework/rastrillo/migrate";"github.com/carlosframework/docs/internal/docs/linkstore";p "github.com/carlosframework/docs/internal/docs/principalstore") |
| +func principalStep(ctx context.Context,d *db.DB,phase string)int64{ |
| + _,e:=migrate.Apply(ctx,d,migrate.Merge(linkstore.Schema,p.Schema));must(e) |
| + s,e:=p.New(d.Writer(),p.Config{Issuer:"https://id.oficina.works",Team:"A1",RequireMembership:func(context.Context,p.MembershipRequest)error{return nil}});must(e) |
| + v,e:=s.EnsureHome(ctx,p.HomeIdentity{Issuer:"https://id.oficina.works",Subject:"synthetic-member"});must(e) |
| + if v.VerifiedAddress!=nil{panic("invented address")};return v.ID |
| +} |
diff --git a/internal/docs/principalstore/testdata/compat_old.go.txt b/internal/docs/principalstore/testdata/compat_old.go.txt| new file mode 100644 |
| index 0000000..c16bb3c |
| --- /dev/null |
| +++ b/internal/docs/principalstore/testdata/compat_old.go.txt |
| @@ -0,0 +1,3 @@ |
| +package main |
| +import("context";"github.com/carlosframework/rastrillo/db") |
| +func principalStep(context.Context,*db.DB,string)int64{return 0} |
diff --git a/scripts/check-principal-compat.py b/scripts/check-principal-compat.py| new file mode 100755 |
| index 0000000..1e78f44 |
| --- /dev/null |
| +++ b/scripts/check-principal-compat.py |
| @@ -0,0 +1,57 @@ |
| +#!/usr/bin/env python3 |
| +"""Build frozen old/new store probes and exercise disposable rollback databases.""" |
| +import argparse |
| +import io |
| +import json |
| +import pathlib |
| +import subprocess |
| +import tarfile |
| + |
| +parser = argparse.ArgumentParser() |
| +parser.add_argument("--artifacts", required=True) |
| +args = parser.parse_args() |
| +repo = pathlib.Path(__file__).resolve().parents[1] |
| +out = pathlib.Path(args.artifacts).resolve() |
| +out.mkdir(parents=True, exist_ok=False) |
| +current = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() |
| +versions = {"old876": "87662f5", "old8a": "8a126727", "old3ed": "3ed7665", "new": current} |
| +fixtures = repo / "internal/docs/principalstore/testdata" |
| +records = [] |
| +for name, rev in versions.items(): |
| + sha = subprocess.check_output(["git", "rev-parse", rev], cwd=repo, text=True).strip() |
| + src = out / name |
| + src.mkdir() |
| + archive = subprocess.check_output(["git", "archive", sha], cwd=repo) |
| + with tarfile.open(fileobj=io.BytesIO(archive)) as tree: |
| + tree.extractall(src, filter="data") |
| + cmd = src / "cmd/principal-compat" |
| + cmd.mkdir() |
| + (cmd / "main.go").write_bytes((fixtures / "compat_main.go.txt").read_bytes()) |
| + step = "new" if name == "new" else "old" |
| + (cmd / "step.go").write_bytes((fixtures / f"compat_{step}.go.txt").read_bytes()) |
| + with (out / f"{name}-build.log").open("wb") as log: |
| + subprocess.run(["go", "build", "-o", str(out / f"{name}-proof"), "./cmd/principal-compat"], cwd=src, stdout=log, stderr=log, check=True) |
| + records.append({"name": name, "sha": sha}) |
| +(out / "builds.json").write_text(json.dumps(records, indent=2)) |
| +results = {} |
| +for old in ["old876", "old8a", "old3ed"]: |
| + db = out / f"{old}-cycle.db" |
| + phases = [] |
| + for index, binary in enumerate([old, "new", old, "new"]): |
| + phase = f"{old}-{index}" |
| + with (out / f"{phase}.stderr").open("wb") as log: |
| + raw = subprocess.check_output([str(out / f"{binary}-proof"), "--db", str(db), "--address", f"synthetic-{index}@example.invalid", "--phase", phase], cwd=out / binary, stderr=log) |
| + (out / f"{phase}.json").write_bytes(raw) |
| + result = json.loads(raw) |
| + if result["local_id"] != index + 1: |
| + raise RuntimeError("old/new allocation or LastInsertId changed") |
| + if index and any(result[k] != phases[0][k] for k in ["owner", "grant_author", "sqid", "content_hash", "encodings", "text"]): |
| + raise RuntimeError("owner/grant/content changed across rollback") |
| + if binary == "new" and (result["home_id"] != 2**52 or result["home_owned"] != 1): |
| + raise RuntimeError("Home identity/ownership changed") |
| + if index == 2 and result["home_owned"] != 1: |
| + raise RuntimeError("old binary lost Home ownership") |
| + phases.append(result) |
| + results[old] = phases |
| +(out / "verified.json").write_text(json.dumps({"candidate": current, "versions": records, "cycles": results}, indent=2)) |
| +print(out / "verified.json") |