Add isolated directory schema and membership lookup store
4 files changed,
+542
−0
implementation/directory-store.md+13 −0internal/home/directorydata/migrations/0001_directory.sql+23 −0internal/home/directorydata/store.go+251 −0internal/home/directorydata/store_test.go+255 −0
CI passed — run details
diff --git a/implementation/directory-store.md b/implementation/directory-store.md| new file mode 100644 |
| index 0000000..672cd78 |
| --- /dev/null |
| +++ b/implementation/directory-store.md |
| @@ -0,0 +1,13 @@ |
| +# Directory store implementation slice |
| + |
| +Source design: approved Home fa64e286990762aecc6913bb9076a3cb75b088c3, discussion24. This branch implements only the directory-owned additive schema, sealed-name helper and membership-derived lookup. Discussion25 owns code review. No runtime schema merge, route, UI, owner/provisioning write entrypoint, roster HTTP or deployment is included. |
| + |
| +`internal/home/directorydata.Schema` is a separate migration namespace. Future integration merges it into `runtime.go` directorySchema, never credential BootSchema. It creates teams, team_instances and memberships only. Teams have AUTOINCREMENT IDs, unique sqid/label, sealed name, creation time and per-team epoch. No people/address/credential schema is applied. ID allocation and name sealing belong in the future audited caller transaction; the helper never inserts a team or owner. The test fixture reserves a row with a non-plaintext placeholder, seals with the allocated ID and updates it before commit. |
| + |
| +`New(db, sealer, Config{Apps, ValidateRoute})` copies the compiled app slug list. A required caller route-validation policy enforces provisioning origin/consumer rules; production may derive the canonical hostname while a reviewed isolated pilot may use explicit origins. The store enforces canonical HTTPS origins and bounded identifiers first. This avoids duplicating every authoritative directory row in immutable process configuration, which the approved design does not require. The caller owns the policy closure and must keep it safe for concurrent reads. |
| + |
| +`TeamForMember(ctx, personRef, sqid)` returns only an existing membership, with the same fixed error for foreign and unknown teams. `Choices(ctx, personRef, app)` returns current membership-derived live instances ordered by immutable team ID; pending/disabled instances are omitted. Invalid stored configuration refuses the entire list. The read cap is 10,000 matching rows, with overflow refused rather than a partial list. These are navigation facts, not item grants or a roster cache. Every call queries current records; membership removal is reflected without a process restart. |
| + |
| +`SealTeamName(teamID, name)` authenticates up to 256 UTF-8 bytes under purpose directory.team-name/<immutable integer ID>. No plaintext name is bound to SQL by store code. Caller writes only the returned bytes. Errors are fixed and do not wrap lower-level messages or user values. DNS label reservation and sqid allocation remain future provisioning responsibilities; this read store checks their structural form. |
| + |
| +Validation: full make test passed, plus go test -race ./internal/home/directorydata. Tests cover zero/one/two-team isolation, owner/member vocabulary, removal, disabled/pending instances, no automatic membership, raw DB/WAL/SHM plaintext scan, swapped/corrupt name ciphertext refusal, ID non-reuse, migration idempotency and schema constraints, route-policy rejection, unknown lifecycle, whole-list refusal and error privacy. Two Go overlay mutations failed the intended tests: removing the subject predicate exposes the other team's choice, and removing ID-specific sealing accepts a swapped name. Artifacts /tmp/oficina-directory-store-test.log and /tmp/oficina-directory-store-mutations. Independent code review and actual branch CI remain required; no canary is claimed for this unmounted package. |
diff --git a/internal/home/directorydata/migrations/0001_directory.sql b/internal/home/directorydata/migrations/0001_directory.sql| new file mode 100644 |
| index 0000000..dfc651e |
| --- /dev/null |
| +++ b/internal/home/directorydata/migrations/0001_directory.sql |
| @@ -0,0 +1,23 @@ |
| +CREATE TABLE teams ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + sqid TEXT NOT NULL UNIQUE CHECK(length(sqid) BETWEEN 1 AND 64 AND sqid NOT GLOB '*[^a-zA-Z0-9]*'), |
| + label TEXT NOT NULL UNIQUE CHECK(length(label) BETWEEN 1 AND 63 AND label NOT GLOB '*[^a-z0-9-]*' AND substr(label,1,1) != '-' AND substr(label,-1,1) != '-'), |
| + sealed_name BLOB NOT NULL CHECK(typeof(sealed_name) = 'blob' AND length(sealed_name) BETWEEN 29 AND 284), |
| + created_at INTEGER NOT NULL CHECK(created_at > 0), |
| + roster_epoch INTEGER NOT NULL DEFAULT 1 CHECK(typeof(roster_epoch) = 'integer' AND roster_epoch > 0) |
| +); |
| +CREATE TABLE team_instances ( |
| + team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, |
| + app TEXT NOT NULL, |
| + origin TEXT NOT NULL UNIQUE, |
| + consumer_id TEXT NOT NULL UNIQUE, |
| + state TEXT NOT NULL CHECK(state IN ('pending', 'live', 'disabled')), |
| + PRIMARY KEY(team_id, app) |
| +); |
| +CREATE TABLE memberships ( |
| + team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, |
| + person_ref TEXT NOT NULL CHECK(length(person_ref) = 29 AND substr(person_ref,1,3) = 'pr_' AND substr(person_ref,4) NOT GLOB '*[^a-z2-7]*'), |
| + role TEXT NOT NULL CHECK(role IN ('owner', 'member')), |
| + PRIMARY KEY(team_id, person_ref) |
| +); |
| +CREATE INDEX memberships_person ON memberships(person_ref, team_id); |
diff --git a/internal/home/directorydata/store.go b/internal/home/directorydata/store.go| new file mode 100644 |
| index 0000000..f5092a0 |
| --- /dev/null |
| +++ b/internal/home/directorydata/store.go |
| @@ -0,0 +1,251 @@ |
| +// Package directorydata holds directory-owned team records. Its read methods |
| +// establish navigation choices only; applications still decide item access. |
| +package directorydata |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "embed" |
| + "errors" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "unicode" |
| + "unicode/utf8" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrations embed.FS |
| + |
| +// Schema belongs in the directory role's schema, never the credential schema. |
| +// It is not mounted by this package. |
| +var Schema = migrate.MustFromFS(migrations, "oficina_directory") |
| + |
| +var ( |
| + ErrInvalid = errors.New("invalid directory configuration or input") |
| + ErrUnavailable = errors.New("directory unavailable") |
| + ErrNotFound = errors.New("directory team not found") |
| +) |
| + |
| +// Sealer must authenticate ciphertext and separate purposes. |
| +type Sealer interface { |
| + Seal(string, []byte) ([]byte, error) |
| + Open(string, []byte) ([]byte, error) |
| +} |
| + |
| +// Route describes a stored destination for the caller's provisioning policy. |
| +type Route struct { |
| + TeamID int64 |
| + TeamSQID, Label, App, Origin, ConsumerID string |
| +} |
| + |
| +// Config supplies the compiled application registry and destination policy. |
| +// ValidateRoute must not authorize a destination from request input. Production |
| +// can check the prescribed team hostname; an isolated pilot can use exact pins. |
| +type Config struct { |
| + Apps []string |
| + ValidateRoute func(Route) bool |
| +} |
| + |
| +type Team struct { |
| + ID int64 |
| + SQID, Label, Name, Role string |
| +} |
| + |
| +type Choice struct { |
| + Team |
| + App, Origin, ConsumerID string |
| +} |
| + |
| +type Store struct { |
| + db *sql.DB |
| + sealer Sealer |
| + apps map[string]bool |
| + validateRoute func(Route) bool |
| +} |
| + |
| +func New(db *sql.DB, sealer Sealer, cfg Config) (*Store, error) { |
| + if db == nil || sealer == nil || cfg.ValidateRoute == nil || len(cfg.Apps) == 0 || len(cfg.Apps) > 100 { |
| + return nil, ErrInvalid |
| + } |
| + s := &Store{db: db, sealer: sealer, apps: make(map[string]bool, len(cfg.Apps)), validateRoute: cfg.ValidateRoute} |
| + for _, app := range cfg.Apps { |
| + if !validLabel(app) || s.apps[app] { |
| + return nil, ErrInvalid |
| + } |
| + s.apps[app] = true |
| + } |
| + return s, nil |
| +} |
| + |
| +// SealTeamName prepares a name for an explicitly authorized caller-owned write |
| +// transaction. It neither inserts a team nor creates a membership. The caller |
| +// must reserve a new, immutable team ID before sealing and commit both together. |
| +func (s *Store) SealTeamName(teamID int64, name string) ([]byte, error) { |
| + if teamID <= 0 || !validName(name) { |
| + return nil, ErrInvalid |
| + } |
| + b, err := s.sealer.Seal(namePurpose(teamID), []byte(name)) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return b, nil |
| +} |
| + |
| +// TeamForMember does not distinguish an unknown team from another person's team. |
| +func (s *Store) TeamForMember(ctx context.Context, subject, sqid string) (Team, error) { |
| + if !validSubject(subject) || !alnum(sqid, 64) { |
| + return Team{}, ErrNotFound |
| + } |
| + var t Team |
| + var sealed []byte |
| + err := s.db.QueryRowContext(ctx, `SELECT t.id,t.sqid,t.label,t.sealed_name,m.role FROM teams t JOIN memberships m ON m.team_id=t.id WHERE t.sqid=? AND m.person_ref=?`, sqid, subject).Scan(&t.ID, &t.SQID, &t.Label, &sealed, &t.Role) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return Team{}, ErrNotFound |
| + } |
| + if err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + if err = s.openTeam(&t, sealed); err != nil { |
| + return Team{}, err |
| + } |
| + return t, nil |
| +} |
| + |
| +// Choices returns only current memberships with live instances, oldest team |
| +// first. Invalid stored configuration refuses the whole result, never a prefix. |
| +func (s *Store) Choices(ctx context.Context, subject, app string) ([]Choice, error) { |
| + if !validSubject(subject) || !s.apps[app] { |
| + return nil, ErrInvalid |
| + } |
| + rows, err := s.db.QueryContext(ctx, `SELECT t.id,t.sqid,t.label,t.sealed_name,m.role,i.app,i.origin,i.consumer_id,i.state FROM memberships m JOIN teams t ON t.id=m.team_id JOIN team_instances i ON i.team_id=t.id WHERE m.person_ref=? AND i.app=? ORDER BY t.id LIMIT 10001`, subject, app) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + defer rows.Close() |
| + choices := make([]Choice, 0) |
| + count := 0 |
| + for rows.Next() { |
| + count++ |
| + if count > 10000 { |
| + return nil, ErrUnavailable |
| + } |
| + var c Choice |
| + var sealed []byte |
| + var state string |
| + if rows.Scan(&c.ID, &c.SQID, &c.Label, &sealed, &c.Role, &c.App, &c.Origin, &c.ConsumerID, &state) != nil { |
| + return nil, ErrUnavailable |
| + } |
| + if state != "live" && state != "pending" && state != "disabled" { |
| + return nil, ErrUnavailable |
| + } |
| + if state != "live" { |
| + continue |
| + } |
| + route := Route{TeamID: c.ID, TeamSQID: c.SQID, Label: c.Label, App: c.App, Origin: c.Origin, ConsumerID: c.ConsumerID} |
| + if !validOrigin(c.Origin) || !validConsumer(c.ConsumerID) || !s.validateRoute(route) { |
| + return nil, ErrUnavailable |
| + } |
| + if err = s.openTeam(&c.Team, sealed); err != nil { |
| + return nil, err |
| + } |
| + choices = append(choices, c) |
| + } |
| + if rows.Err() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return choices, nil |
| +} |
| + |
| +func (s *Store) openTeam(t *Team, sealed []byte) error { |
| + if t.ID <= 0 || !alnum(t.SQID, 64) || !validLabel(t.Label) || (t.Role != "owner" && t.Role != "member") { |
| + return ErrUnavailable |
| + } |
| + b, err := s.sealer.Open(namePurpose(t.ID), sealed) |
| + if err != nil || !validName(string(b)) { |
| + return ErrUnavailable |
| + } |
| + t.Name = string(b) |
| + return nil |
| +} |
| +func namePurpose(id int64) string { return "directory.team-name/" + strconv.FormatInt(id, 10) } |
| + |
| +func alnum(s string, max int) bool { |
| + if len(s) == 0 || len(s) > max { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validSubject(s string) bool { |
| + if len(s) != 29 || !strings.HasPrefix(s, "pr_") { |
| + return false |
| + } |
| + for _, c := range s[3:] { |
| + if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validLabel(s string) bool { |
| + if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validName(s string) bool { |
| + if len(s) == 0 || len(s) > 256 || !utf8.ValidString(s) || strings.TrimSpace(s) == "" { |
| + return false |
| + } |
| + for _, r := range s { |
| + if unicode.IsControl(r) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func validOrigin(s string) bool { |
| + u, err := url.Parse(s) |
| + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery || u.Opaque != "" || u.Port() != "" || u.Host != strings.ToLower(u.Host) { |
| + return false |
| + } |
| + parts := strings.Split(u.Host, ".") |
| + if len(parts) < 2 { |
| + return false |
| + } |
| + for _, p := range parts { |
| + if len(p) == 0 || len(p) > 63 || p[0] == '-' || p[len(p)-1] == '-' { |
| + return false |
| + } |
| + for _, c := range p { |
| + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { |
| + return false |
| + } |
| + } |
| + } |
| + return len(u.Host) <= 253 |
| +} |
| + |
| +func validConsumer(s string) bool { |
| + if len(s) == 0 || len(s) > 64 { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/home/directorydata/store_test.go b/internal/home/directorydata/store_test.go| new file mode 100644 |
| index 0000000..b512f0d |
| --- /dev/null |
| +++ b/internal/home/directorydata/store_test.go |
| @@ -0,0 +1,255 @@ |
| +package directorydata |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +const personA = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| +const personB = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| +const privateName = "CANARY-TEAM-NAME-2b71" |
| + |
| +func fixture(t *testing.T) (*Store, *db.DB, string) { |
| + t.Helper() |
| + p := filepath.Join(t.TempDir(), "directory.db") |
| + d, e := db.Open(p, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, e = migrate.Apply(context.Background(), d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + sealer, e := seal.New(bytes.Repeat([]byte{17}, 32)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + s, e := New(d.Writer(), sealer, Config{Apps: []string{"docs", "sheets"}, ValidateRoute: func(r Route) bool { return r.Origin == "https://"+r.Label+"."+r.App+".oficina.works" }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return s, d, p |
| +} |
| +func exec(t *testing.T, d *sql.DB, q string, args ...any) { |
| + t.Helper() |
| + if _, e := d.Exec(q, args...); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| + |
| +// Only test setup performs these explicit operator-owned writes. |
| +func team(t *testing.T, s *Store, sqid, label, name string) int64 { |
| + t.Helper() |
| + tx, e := s.db.Begin() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer tx.Rollback() |
| + r, e := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + id, e := r.LastInsertId() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + b, e := s.SealTeamName(id, name) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = tx.Exec(`UPDATE teams SET sealed_name=? WHERE id=?`, b, id); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.Commit(); e != nil { |
| + t.Fatal(e) |
| + } |
| + return id |
| +} |
| +func member(t *testing.T, s *Store, id int64, p, role string) { |
| + t.Helper() |
| + exec(t, s.db, `INSERT INTO memberships VALUES(?,?,?)`, id, p, role) |
| +} |
| +func instance(t *testing.T, s *Store, id int64, label, app, state string) { |
| + t.Helper() |
| + exec(t, s.db, `INSERT INTO team_instances VALUES(?,?,?,?,?)`, id, app, "https://"+label+"."+app+".oficina.works", label+"-"+app, state) |
| +} |
| +func TestIsolationRemovalAndNoAutomaticOwner(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + ctx := context.Background() |
| + a := team(t, s, "A1", "alpha", privateName) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + instance(t, s, a, "alpha", "docs", "live") |
| + instance(t, s, b, "beta", "docs", "live") |
| + instance(t, s, a, "alpha", "sheets", "pending") |
| + for _, p := range []string{personA, personB} { |
| + got, e := s.Choices(ctx, p, "docs") |
| + if e != nil || len(got) != 0 { |
| + t.Fatal(got, e) |
| + } |
| + } |
| + member(t, s, a, personA, "owner") |
| + member(t, s, b, personB, "member") |
| + got, e := s.Choices(ctx, personA, "docs") |
| + if e != nil || len(got) != 1 || got[0].ID != a || got[0].Name != privateName || got[0].Role != "owner" { |
| + t.Fatal(got, e) |
| + } |
| + for _, sqid := range []string{"B2", "Missing"} { |
| + if _, e = s.TeamForMember(ctx, personA, sqid); !errors.Is(e, ErrNotFound) { |
| + t.Fatal(e) |
| + } |
| + } |
| + if got, e = s.Choices(ctx, personA, "sheets"); e != nil || len(got) != 0 { |
| + t.Fatal(got, e) |
| + } |
| + member(t, s, b, personA, "member") |
| + got, e = s.Choices(ctx, personA, "docs") |
| + if e != nil || len(got) != 2 || got[0].ID != a || got[1].ID != b { |
| + t.Fatal(got, e) |
| + } |
| + exec(t, s.db, `DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, personA) |
| + if _, e = s.TeamForMember(ctx, personA, "A1"); !errors.Is(e, ErrNotFound) { |
| + t.Fatal(e) |
| + } |
| + got, e = s.Choices(ctx, personA, "docs") |
| + if e != nil || len(got) != 1 || got[0].ID != b { |
| + t.Fatal(got, e) |
| + } |
| + exec(t, s.db, `UPDATE team_instances SET state='disabled' WHERE team_id=?`, b) |
| + if got, e = s.Choices(ctx, personA, "docs"); e != nil || len(got) != 0 { |
| + t.Fatal(got, e) |
| + } |
| +} |
| +func TestSealedNamePrivacyAndBinding(t *testing.T) { |
| + s, _, p := fixture(t) |
| + a := team(t, s, "A1", "alpha", privateName) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + member(t, s, a, personA, "member") |
| + member(t, s, b, personA, "member") |
| + sealtest.AssertNoPlaintext(t, p) |
| + var ciphertext []byte |
| + if e := s.db.QueryRow(`SELECT sealed_name FROM teams WHERE id=?`, a).Scan(&ciphertext); e != nil { |
| + t.Fatal(e) |
| + } |
| + exec(t, s.db, `UPDATE teams SET sealed_name=? WHERE id=?`, ciphertext, b) |
| + _, e := s.TeamForMember(context.Background(), personA, "B2") |
| + if !errors.Is(e, ErrUnavailable) || strings.Contains(e.Error(), privateName) { |
| + t.Fatal(e) |
| + } |
| + ciphertext[len(ciphertext)-1] ^= 1 |
| + exec(t, s.db, `UPDATE teams SET sealed_name=? WHERE id=?`, ciphertext, a) |
| + if _, e = s.TeamForMember(context.Background(), personA, "A1"); !errors.Is(e, ErrUnavailable) { |
| + t.Fatal(e) |
| + } |
| + sealtest.AssertNoPlaintext(t, p) |
| +} |
| +func TestSchemaStableIDsConstraintsAndPrivacy(t *testing.T) { |
| + s, d, _ := fixture(t) |
| + a := team(t, s, "A1", "alpha", privateName) |
| + exec(t, s.db, `DELETE FROM teams WHERE id=?`, a) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + if b <= a { |
| + t.Fatalf("reused ID %d %d", a, b) |
| + } |
| + if _, e := migrate.Apply(context.Background(), d, Schema); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, q := range []struct { |
| + s string |
| + a []any |
| + }{ |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{b, "owner@example.invalid", "owner"}}, |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{b, personA, "admin"}}, |
| + {`INSERT INTO memberships VALUES(?,?,?)`, []any{a, personA, "owner"}}, |
| + {`UPDATE teams SET roster_epoch=0`, nil}, |
| + {`UPDATE teams SET sealed_name=?`, []any{privateName}}, |
| + {`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES('B2','gamma',zeroblob(29),1)`, nil}, |
| + } { |
| + if _, e := s.db.Exec(q.s, q.a...); e == nil { |
| + t.Fatalf("accepted %s", q.s) |
| + } |
| + } |
| + rows, e := s.db.Query(`SELECT sql FROM sqlite_master WHERE type='table'`) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + var ddl string |
| + if e = rows.Scan(&ddl); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, forbidden := range []string{"email", "address", "credentials", "CREATE TABLE people", "CREATE TABLE sessions"} { |
| + if strings.Contains(ddl, forbidden) { |
| + t.Fatalf("forbidden schema %s", forbidden) |
| + } |
| + } |
| + } |
| +} |
| +func TestUnsafeStoredRouteRefusesEntireList(t *testing.T) { |
| + for _, mutation := range []string{ |
| + `UPDATE team_instances SET origin='https://attacker.example' WHERE team_id=2`, |
| + `UPDATE team_instances SET origin='https://beta.docs.oficina.works/path' WHERE team_id=2`, |
| + `UPDATE team_instances SET consumer_id='bad@identity' WHERE team_id=2`, |
| + `PRAGMA ignore_check_constraints=ON; UPDATE team_instances SET state='unknown' WHERE team_id=2`, |
| + } { |
| + t.Run(mutation, func(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + a := team(t, s, "A1", "alpha", privateName) |
| + b := team(t, s, "B2", "beta", "Other team") |
| + for i, id := range []int64{a, b} { |
| + member(t, s, id, personA, "member") |
| + instance(t, s, id, []string{"alpha", "beta"}[i], "docs", "live") |
| + } |
| + exec(t, s.db, mutation) |
| + got, e := s.Choices(context.Background(), personA, "docs") |
| + if !errors.Is(e, ErrUnavailable) || got != nil { |
| + t.Fatal(got, e) |
| + } |
| + }) |
| + } |
| +} |
| +func TestInputAndErrorPrivacy(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + for _, origin := range []string{"http://alpha.example", "https://user@example.com", "https://example.com/", "https://example.com?", "https://EXAMPLE.com", "https://example.com:443", "https://example.com#x", "https://example.com.", "https://localhost", "https://a..example"} { |
| + if validOrigin(origin) { |
| + t.Errorf("accepted %q", origin) |
| + } |
| + } |
| + if !validOrigin("https://alpha.docs.oficina.works") { |
| + t.Fatal("valid origin refused") |
| + } |
| + for _, cfg := range []Config{{}, {Apps: []string{"docs"}}, {Apps: []string{"docs", "docs"}, ValidateRoute: func(Route) bool { return true }}, {Apps: []string{"bad/app"}, ValidateRoute: func(Route) bool { return true }}} { |
| + if _, e := New(s.db, s.sealer, cfg); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + } |
| + for _, name := range []string{"", strings.Repeat("x", 257), "\x00private", string([]byte{255}), " "} { |
| + if _, e := s.SealTeamName(1, name); !errors.Is(e, ErrInvalid) || strings.Contains(e.Error(), "private") { |
| + t.Fatal(e) |
| + } |
| + } |
| + if _, e := s.SealTeamName(0, privateName); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + if _, e := s.Choices(context.Background(), personA, "unknown"); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + if _, e := s.Choices(context.Background(), "person@example.invalid", "docs"); !errors.Is(e, ErrInvalid) { |
| + t.Fatal(e) |
| + } |
| + s.db.Close() |
| + _, e := s.Choices(context.Background(), personA, "docs") |
| + if !errors.Is(e, ErrUnavailable) || strings.Contains(e.Error(), privateName) { |
| + t.Fatal(e) |
| + } |
| +} |