diff --git a/db/access.go b/db/access.go new file mode 100644 index 0000000000000000000000000000000000000000..f2f0e586745e9171566a9f3b6bd6adb68ae11528 --- /dev/null +++ b/db/access.go @@ -0,0 +1,103 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "go.bigb.es/sourcehut-dolt/core" +) + +// ACLEntry is one row of the access table, joined with the grantee's username +// for display in the settings UI. +type ACLEntry struct { + ID int + RepoID int + UserID int + Username string + Mode core.AccessMode + Created time.Time + Updated time.Time +} + +// EffectiveAccess returns the ACL grant a user holds on a repository, or nil if +// the user has no access entry. This is exactly the aclMode input that +// core.Allowed expects: it reflects only explicit ACL grants, never ownership or +// visibility (those are the caller's to combine via core.Allowed). A nil result +// with a nil error means "no grant", which is not an error condition. +func (s *Store) EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error) { + const q = `SELECT mode FROM access WHERE user_id = $1 AND repo_id = $2` + var mode string + err := s.q.QueryRowContext(ctx, q, userID, repoID).Scan(&mode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("effective access user=%d repo=%d: %w", userID, repoID, err) + } + m := core.AccessMode(mode) + return &m, nil +} + +// ListACL returns every access entry for a repository, ordered by username, with +// the grantee's username resolved for display. +func (s *Store) ListACL(ctx context.Context, repoID int) ([]*ACLEntry, error) { + const q = ` +SELECT a.id, a.repo_id, a.user_id, COALESCE(u.username, ''), a.mode, a.created, a.updated +FROM access a +JOIN "user" u ON u.id = a.user_id +WHERE a.repo_id = $1 +ORDER BY u.username ASC, a.id ASC` + rows, err := s.q.QueryContext(ctx, q, repoID) + if err != nil { + return nil, fmt.Errorf("list acl repo=%d: %w", repoID, err) + } + defer rows.Close() + var entries []*ACLEntry + for rows.Next() { + var ( + e ACLEntry + mode string + ) + if err := rows.Scan(&e.ID, &e.RepoID, &e.UserID, &e.Username, + &mode, &e.Created, &e.Updated); err != nil { + return nil, fmt.Errorf("scan acl: %w", err) + } + e.Mode = core.AccessMode(mode) + entries = append(entries, &e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate acl: %w", err) + } + return entries, nil +} + +// UpsertACL grants (or updates) userID's access mode on repoID. If a grant +// already exists it is updated in place (and updated is bumped); otherwise a new +// row is inserted. +func (s *Store) UpsertACL(ctx context.Context, repoID, userID int, mode core.AccessMode) error { + now := time.Now().UTC() + const q = ` +INSERT INTO access (created, updated, repo_id, user_id, mode) +VALUES ($1, $1, $2, $3, $4) +ON CONFLICT ON CONSTRAINT uq_access_user_id_repo_id +DO UPDATE SET mode = EXCLUDED.mode, updated = EXCLUDED.updated` + _, err := s.q.ExecContext(ctx, q, now, repoID, userID, string(mode)) + if err != nil { + return fmt.Errorf("upsert acl repo=%d user=%d: %w", repoID, userID, err) + } + return nil +} + +// DeleteACL revokes userID's access on repoID. Returns ErrNotFound if no grant +// existed. +func (s *Store) DeleteACL(ctx context.Context, repoID, userID int) error { + res, err := s.q.ExecContext(ctx, + `DELETE FROM access WHERE repo_id = $1 AND user_id = $2`, repoID, userID) + if err != nil { + return fmt.Errorf("delete acl repo=%d user=%d: %w", repoID, userID, err) + } + return requireOne(res, "delete acl") +} diff --git a/db/access_test.go b/db/access_test.go new file mode 100644 index 0000000000000000000000000000000000000000..317de446d7c225f9d91e57b1cab3eb5dde2868a4 --- /dev/null +++ b/db/access_test.go @@ -0,0 +1,78 @@ +package db + +import ( + "context" + "errors" + "testing" + + "go.bigb.es/sourcehut-dolt/core" +) + +func TestEffectiveAccessAndACL(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + owner := insertUser(t, db, 1, "alice", core.UserTypeUser) + grantee := insertUser(t, db, 2, "bob", core.UserTypeUser) + repo := mkRepo(t, s, ctx, owner, "alice", "widgets", core.VisibilityPrivate) + + // No grant yet: nil, nil. + mode, err := s.EffectiveAccess(ctx, grantee, repo.ID) + if err != nil { + t.Fatalf("effective access: %v", err) + } + if mode != nil { + t.Fatalf("expected nil access, got %v", *mode) + } + + // Grant RO, then verify. + if err := s.UpsertACL(ctx, repo.ID, grantee, core.AccessRO); err != nil { + t.Fatalf("upsert RO: %v", err) + } + mode, err = s.EffectiveAccess(ctx, grantee, repo.ID) + if err != nil { + t.Fatalf("effective access after grant: %v", err) + } + if mode == nil || *mode != core.AccessRO { + t.Fatalf("expected RO, got %v", mode) + } + + // Upsert to RW updates in place. + if err := s.UpsertACL(ctx, repo.ID, grantee, core.AccessRW); err != nil { + t.Fatalf("upsert RW: %v", err) + } + mode, err = s.EffectiveAccess(ctx, grantee, repo.ID) + if err != nil { + t.Fatalf("effective access after update: %v", err) + } + if mode == nil || *mode != core.AccessRW { + t.Fatalf("expected RW, got %v", mode) + } + + // ListACL resolves the username. + entries, err := s.ListACL(ctx, repo.ID) + if err != nil { + t.Fatalf("list acl: %v", err) + } + if len(entries) != 1 || entries[0].Username != "bob" || entries[0].Mode != core.AccessRW { + t.Fatalf("unexpected acl entries: %+v", entries) + } + + // DeleteACL revokes; effective access returns to nil. + if err := s.DeleteACL(ctx, repo.ID, grantee); err != nil { + t.Fatalf("delete acl: %v", err) + } + mode, err = s.EffectiveAccess(ctx, grantee, repo.ID) + if err != nil { + t.Fatalf("effective access after delete: %v", err) + } + if mode != nil { + t.Fatalf("expected nil after delete, got %v", *mode) + } + + // Deleting again is ErrNotFound. + if err := s.DeleteACL(ctx, repo.ID, grantee); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/db/db_test.go b/db/db_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ddf0daa7e0e44b957ab7de199dea44dc26f70b79 --- /dev/null +++ b/db/db_test.go @@ -0,0 +1,146 @@ +package db + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" + + "go.bigb.es/sourcehut-dolt/core" +) + +// testEnv names the DSN env var that gates the Postgres-backed tests. When +// unset, every test in this package skips with a clear message; the package +// still compiles and its skip path is exercised. +const testEnv = "DOLTSRHT_TEST_PG" + +// newTestStore connects to the Postgres pointed at by DOLTSRHT_TEST_PG, creates +// an isolated scratch schema (doltsrht_test_), applies schema.sql into +// it, and returns a Store bound to a pool whose search_path is that schema. The +// returned cleanup drops the schema and closes the pools. If the env var is +// unset the test is skipped. +// +// Isolation is achieved with a per-test schema rather than a whole database so +// no admin/CREATE DATABASE privilege is required and cleanup is a single +// DROP SCHEMA ... CASCADE. The scratch pool routes every connection to that +// schema via lib/pq's `options=-c search_path=...` startup parameter. +func newTestStore(t *testing.T) (*Store, *sql.DB, func()) { + t.Helper() + base := os.Getenv(testEnv) + if base == "" { + t.Skipf("%s not set; skipping Postgres-backed test (set it to a DSN to run)", testEnv) + } + + admin, err := sql.Open("postgres", base) + if err != nil { + t.Fatalf("open admin pool: %v", err) + } + if err := admin.Ping(); err != nil { + admin.Close() + t.Fatalf("ping %s: %v", testEnv, err) + } + + schema := "doltsrht_test_" + randToken() + if _, err := admin.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil { + admin.Close() + t.Fatalf("create schema %s: %v", schema, err) + } + + scopedDSN, err := withSearchPath(base, schema) + if err != nil { + admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`) + admin.Close() + t.Fatalf("build scoped dsn: %v", err) + } + pool, err := sql.Open("postgres", scopedDSN) + if err != nil { + admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`) + admin.Close() + t.Fatalf("open scoped pool: %v", err) + } + + ddl, err := os.ReadFile("../schema.sql") + if err != nil { + pool.Close() + admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`) + admin.Close() + t.Fatalf("read schema.sql: %v", err) + } + if _, err := pool.Exec(string(ddl)); err != nil { + pool.Close() + admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`) + admin.Close() + t.Fatalf("apply schema.sql: %v", err) + } + + cleanup := func() { + pool.Close() + if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil { + t.Errorf("drop schema %s: %v", schema, err) + } + admin.Close() + } + return NewStore(pool), pool, cleanup +} + +// withSearchPath returns base with a connection option that pins search_path to +// schema for every pooled connection, handling both URL and keyword DSN forms. +func withSearchPath(base, schema string) (string, error) { + opt := "-c search_path=" + schema + if strings.Contains(base, "://") { + u, err := url.Parse(base) + if err != nil { + return "", err + } + q := u.Query() + q.Set("options", opt) + u.RawQuery = q.Encode() + return u.String(), nil + } + return base + " options='" + opt + "'", nil +} + +func randToken() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} + +// insertUser inserts a mirror user row and returns its id. +func insertUser(t *testing.T, db *sql.DB, id int, username string, ut core.UserType) int { + t.Helper() + now := time.Now().UTC() + _, err := db.Exec(` +INSERT INTO "user" (id, username, created, updated, email, user_type) +VALUES ($1, $2, $3, $3, $4, $5)`, + id, username, now, username+"@example.test", string(ut)) + if err != nil { + t.Fatalf("insert user %s: %v", username, err) + } + return id +} + +// mkRepo is a convenience for CreateRepo in tests. +func mkRepo(t *testing.T, s *Store, ctx context.Context, ownerID int, ownerName, name string, vis core.Visibility) *core.Repo { + t.Helper() + repo, err := s.CreateRepo(ctx, &core.Repo{ + Name: name, + OwnerID: ownerID, + OwnerName: ownerName, + Path: "/var/lib/dolt/~" + ownerName + "/" + name, + Visibility: vis, + }) + if err != nil { + t.Fatalf("create repo %s: %v", name, err) + } + return repo +} diff --git a/db/keys.go b/db/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..9acdb847c074d363aa6f6f0a52d6d32c0a98b29b --- /dev/null +++ b/db/keys.go @@ -0,0 +1,152 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/lib/pq" + + "go.bigb.es/sourcehut-dolt/core" +) + +// DoltKey is a registered Ed25519 credential (dolt creds / dolt login). PubKey +// is the raw 32-byte public key; Comment and LastUsed are nullable in the +// schema and default to "" / nil. +type DoltKey struct { + ID int + UserID int + KID string + PubKey []byte + Comment string + Created time.Time + LastUsed *time.Time +} + +// KeyAuth is everything the Bearer-JWT verifier needs to authenticate a dolt +// keypair request: the stored public key to verify the JWS signature, plus the +// owning user's identity to build a core.Caller. Suspended is derived by the +// caller from UserType == core.UserTypeSuspended. +type KeyAuth struct { + KeyID int + UserID int + Username string + UserType core.UserType + PubKey []byte +} + +// InsertKey registers a dolt key for a user. kid is base32(SHA-512/224(pubkey)) +// in dolt's alphabet; pubkey is the raw 32-byte Ed25519 public key. A duplicate +// kid (dolt_key.kid UNIQUE) is mapped to ErrKeyExists. Returns the created row. +func (s *Store) InsertKey(ctx context.Context, userID int, kid string, pubkey []byte, comment string) (*DoltKey, error) { + now := time.Now().UTC() + const q = ` +INSERT INTO dolt_key (created, user_id, kid, pubkey, comment) +VALUES ($1, $2, $3, $4, $5) +RETURNING id` + var cmt any + if comment != "" { + cmt = comment + } + var id int + err := s.q.QueryRowContext(ctx, q, now, userID, kid, pubkey, cmt).Scan(&id) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { + return nil, ErrKeyExists + } + return nil, fmt.Errorf("insert dolt key: %w", err) + } + return &DoltKey{ + ID: id, + UserID: userID, + KID: kid, + PubKey: pubkey, + Comment: comment, + Created: now, + }, nil +} + +// KeyByKID resolves a key by its kid and returns the public key together with +// the owning user's identity, for authentication. Returns ErrNotFound if no key +// with that kid is registered. +func (s *Store) KeyByKID(ctx context.Context, kid string) (*KeyAuth, error) { + const q = ` +SELECT k.id, k.pubkey, u.id, COALESCE(u.username, ''), u.user_type +FROM dolt_key k +JOIN "user" u ON u.id = k.user_id +WHERE k.kid = $1` + var ( + ka KeyAuth + userType string + ) + err := s.q.QueryRowContext(ctx, q, kid).Scan( + &ka.KeyID, &ka.PubKey, &ka.UserID, &ka.Username, &userType) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("key by kid %s: %w", kid, err) + } + ka.UserType = core.UserType(userType) + return &ka, nil +} + +// ListKeysByUser returns all of a user's registered dolt keys, newest first. +func (s *Store) ListKeysByUser(ctx context.Context, userID int) ([]*DoltKey, error) { + const q = ` +SELECT id, user_id, kid, pubkey, COALESCE(comment, ''), created, last_used +FROM dolt_key +WHERE user_id = $1 +ORDER BY created DESC, id DESC` + rows, err := s.q.QueryContext(ctx, q, userID) + if err != nil { + return nil, fmt.Errorf("list keys user=%d: %w", userID, err) + } + defer rows.Close() + var keys []*DoltKey + for rows.Next() { + var ( + k DoltKey + lastUsed sql.NullTime + ) + if err := rows.Scan(&k.ID, &k.UserID, &k.KID, &k.PubKey, + &k.Comment, &k.Created, &lastUsed); err != nil { + return nil, fmt.Errorf("scan key: %w", err) + } + if lastUsed.Valid { + t := lastUsed.Time + k.LastUsed = &t + } + keys = append(keys, &k) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate keys: %w", err) + } + return keys, nil +} + +// DeleteKey removes one of a user's keys. It is scoped by userID so a user can +// only delete keys they own; a mismatch (or missing id) yields ErrNotFound. +func (s *Store) DeleteKey(ctx context.Context, id, userID int) error { + res, err := s.q.ExecContext(ctx, + `DELETE FROM dolt_key WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return fmt.Errorf("delete key %d: %w", id, err) + } + return requireOne(res, "delete key") +} + +// TouchKeyLastUsed stamps a key's last_used with the current time, called after +// a successful keypair authentication. Returns ErrNotFound if the kid vanished +// (e.g. the key was deleted concurrently). +func (s *Store) TouchKeyLastUsed(ctx context.Context, kid string) error { + res, err := s.q.ExecContext(ctx, + `UPDATE dolt_key SET last_used = $2 WHERE kid = $1`, kid, time.Now().UTC()) + if err != nil { + return fmt.Errorf("touch key %s: %w", kid, err) + } + return requireOne(res, "touch key") +} diff --git a/db/keys_test.go b/db/keys_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9d0b850ed6bb1c3003d78de8930f83b66ed2eb79 --- /dev/null +++ b/db/keys_test.go @@ -0,0 +1,81 @@ +package db + +import ( + "bytes" + "context" + "errors" + "testing" + + "go.bigb.es/sourcehut-dolt/core" +) + +func TestDoltKeyLifecycle(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + uid := insertUser(t, db, 1, "alice", core.UserTypeUser) + pub := bytes.Repeat([]byte{0xab}, 32) + + key, err := s.InsertKey(ctx, uid, "kid-one", pub, "laptop") + if err != nil { + t.Fatalf("insert key: %v", err) + } + if key.ID == 0 { + t.Fatal("expected non-zero key id") + } + + // Duplicate kid → ErrKeyExists. + if _, err := s.InsertKey(ctx, uid, "kid-one", pub, "dup"); !errors.Is(err, ErrKeyExists) { + t.Fatalf("expected ErrKeyExists, got %v", err) + } + + // KeyByKID returns pubkey + user identity for authn. + ka, err := s.KeyByKID(ctx, "kid-one") + if err != nil { + t.Fatalf("key by kid: %v", err) + } + if ka.UserID != uid || ka.Username != "alice" || ka.UserType != core.UserTypeUser { + t.Fatalf("unexpected key auth identity: %+v", ka) + } + if !bytes.Equal(ka.PubKey, pub) { + t.Fatalf("pubkey mismatch: %x", ka.PubKey) + } + + // Missing kid → ErrNotFound. + if _, err := s.KeyByKID(ctx, "absent"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + + // TouchKeyLastUsed sets last_used. + if err := s.TouchKeyLastUsed(ctx, "kid-one"); err != nil { + t.Fatalf("touch: %v", err) + } + keys, err := s.ListKeysByUser(ctx, uid) + if err != nil { + t.Fatalf("list keys: %v", err) + } + if len(keys) != 1 || keys[0].Comment != "laptop" || keys[0].LastUsed == nil { + t.Fatalf("unexpected keys after touch: %+v", keys) + } + + // Touching an absent kid → ErrNotFound. + if err := s.TouchKeyLastUsed(ctx, "absent"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound touching absent, got %v", err) + } + + // DeleteKey is scoped by user: wrong user cannot delete. + if err := s.DeleteKey(ctx, key.ID, 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound deleting other user's key, got %v", err) + } + if err := s.DeleteKey(ctx, key.ID, uid); err != nil { + t.Fatalf("delete own key: %v", err) + } + keys, err = s.ListKeysByUser(ctx, uid) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(keys) != 0 { + t.Fatalf("expected no keys after delete, got %d", len(keys)) + } +} diff --git a/db/repos.go b/db/repos.go new file mode 100644 index 0000000000000000000000000000000000000000..2f524a61bcaf01917bc398f310f09c82520cc684 --- /dev/null +++ b/db/repos.go @@ -0,0 +1,197 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/lib/pq" + + "go.bigb.es/sourcehut-dolt/core" +) + +// repoSelect is the common projection used by every repository read. It joins +// "user" to resolve the owner's username (core.Repo.OwnerName). description is +// nullable in the schema, so it is coalesced to the empty string. +const repoSelect = ` +SELECT r.id, r.name, COALESCE(r.description, ''), r.owner_id, + COALESCE(u.username, ''), r.path, r.visibility +FROM repository r +JOIN "user" u ON u.id = r.owner_id` + +func scanRepo(sc rowScanner) (*core.Repo, error) { + var ( + r core.Repo + visibility string + ) + if err := sc.Scan(&r.ID, &r.Name, &r.Description, &r.OwnerID, + &r.OwnerName, &r.Path, &visibility); err != nil { + return nil, err + } + r.Visibility = core.Visibility(visibility) + return &r, nil +} + +// CreateRepo inserts a new repository row. It is normally run on a transaction +// (Store.WithTx) so the caller can create the on-disk NBS store in the same +// unit of work and roll both back on failure. r.Name, r.OwnerID, r.Path and +// r.Visibility must be set; ID, created and updated are assigned here and the +// populated repo is returned (with OwnerName carried through from the input, +// which the caller already knows). A uq_repo_owner_id_name violation is mapped +// to ErrNameTaken; any other unique violation (e.g. duplicate path) is returned +// unwrapped so the caller sees the true cause. +func (s *Store) CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error) { + now := time.Now().UTC() + const q = ` +INSERT INTO repository (created, updated, name, description, owner_id, path, visibility) +VALUES ($1, $1, $2, $3, $4, $5, $6) +RETURNING id` + var desc any + if r.Description != "" { + desc = r.Description + } + var id int + err := s.q.QueryRowContext(ctx, q, + now, r.Name, desc, r.OwnerID, r.Path, string(r.Visibility)).Scan(&id) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" && + pqErr.Constraint == "uq_repo_owner_id_name" { + return nil, ErrNameTaken + } + return nil, fmt.Errorf("insert repository: %w", err) + } + out := *r + out.ID = id + return &out, nil +} + +// GetRepoByOwnerAndName resolves a repository by its owner's username and name. +// Returns ErrNotFound if no such repository exists. +func (s *Store) GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error) { + q := repoSelect + ` +WHERE u.username = $1 AND r.name = $2` + repo, err := scanRepo(s.q.QueryRowContext(ctx, q, ownerUsername, name)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get repo %s/%s: %w", ownerUsername, name, err) + } + return repo, nil +} + +// GetRepoByID resolves a repository by its primary key. Returns ErrNotFound if +// no such repository exists. +func (s *Store) GetRepoByID(ctx context.Context, id int) (*core.Repo, error) { + q := repoSelect + ` +WHERE r.id = $1` + repo, err := scanRepo(s.q.QueryRowContext(ctx, q, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get repo %d: %w", id, err) + } + return repo, nil +} + +// ListReposByOwner lists the repositories owned by ownerUsername that viewer is +// allowed to see, newest first. The listing rule (distinct from clone/browse +// authorization) is: +// +// - The owner, and any user holding an ACL entry on a repo, always see it +// regardless of visibility (including PRIVATE and UNLISTED). +// - Everyone else — including anonymous viewers (viewer == nil) — sees only +// PUBLIC repositories. UNLISTED repositories are never listed to non-owners +// without an ACL, and PRIVATE ones are never listed either. +// +// viewer is the browsing principal; pass nil for an anonymous request. +func (s *Store) ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error) { + viewerID := 0 + if viewer != nil { + viewerID = viewer.UserID + } + q := repoSelect + ` +WHERE u.username = $1 AND ( + r.visibility = 'PUBLIC' + OR r.owner_id = $2 + OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $2) +) +ORDER BY r.created DESC, r.id DESC` + return s.queryRepos(ctx, q, ownerUsername, viewerID) +} + +// ListReposForDashboard lists every repository the given user owns or holds an +// ACL entry on, newest first. Used for the signed-in user's dashboard. +func (s *Store) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) { + q := repoSelect + ` +WHERE r.owner_id = $1 + OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = $1) +ORDER BY r.created DESC, r.id DESC` + return s.queryRepos(ctx, q, userID) +} + +func (s *Store) queryRepos(ctx context.Context, q string, args ...any) ([]*core.Repo, error) { + rows, err := s.q.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + defer rows.Close() + var repos []*core.Repo + for rows.Next() { + repo, err := scanRepo(rows) + if err != nil { + return nil, fmt.Errorf("scan repo: %w", err) + } + repos = append(repos, repo) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate repos: %w", err) + } + return repos, nil +} + +// UpdateRepo updates the mutable repository fields (description and visibility) +// and bumps updated. Returns ErrNotFound if id does not exist. +func (s *Store) UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error { + const q = ` +UPDATE repository +SET description = $2, visibility = $3, updated = $4 +WHERE id = $1` + var desc any + if description != "" { + desc = description + } + res, err := s.q.ExecContext(ctx, q, id, desc, string(visibility), time.Now().UTC()) + if err != nil { + return fmt.Errorf("update repo %d: %w", id, err) + } + return requireOne(res, "update repo") +} + +// DeleteRepo removes a repository row (cascading to its access entries). The +// on-disk store removal is the caller's responsibility. Returns ErrNotFound if +// id does not exist. +func (s *Store) DeleteRepo(ctx context.Context, id int) error { + res, err := s.q.ExecContext(ctx, `DELETE FROM repository WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete repo %d: %w", id, err) + } + return requireOne(res, "delete repo") +} + +// requireOne turns a zero-rows-affected result into ErrNotFound so that missing +// targets surface loudly instead of passing silently. +func requireOne(res sql.Result, what string) error { + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%s: rows affected: %w", what, err) + } + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/db/repos_test.go b/db/repos_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6a33b10db409a39029c84e1ea4e250bbf0dbca49 --- /dev/null +++ b/db/repos_test.go @@ -0,0 +1,200 @@ +package db + +import ( + "context" + "errors" + "testing" + + "go.bigb.es/sourcehut-dolt/core" +) + +func TestCreateAndGetRepo(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + insertUser(t, db, 1, "alice", core.UserTypeUser) + repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) + if repo.ID == 0 { + t.Fatal("expected non-zero repo id") + } + + got, err := s.GetRepoByOwnerAndName(ctx, "alice", "widgets") + if err != nil { + t.Fatalf("get by owner/name: %v", err) + } + if got.ID != repo.ID || got.OwnerName != "alice" || got.Visibility != core.VisibilityPublic { + t.Fatalf("unexpected repo: %+v", got) + } + + byID, err := s.GetRepoByID(ctx, repo.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if byID.Name != "widgets" || byID.OwnerName != "alice" { + t.Fatalf("unexpected repo by id: %+v", byID) + } +} + +func TestCreateRepoDuplicateName(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + insertUser(t, db, 1, "alice", core.UserTypeUser) + mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) + + _, err := s.CreateRepo(ctx, &core.Repo{ + Name: "widgets", OwnerID: 1, OwnerName: "alice", + Path: "/var/lib/dolt/~alice/widgets2", Visibility: core.VisibilityPrivate, + }) + if !errors.Is(err, ErrNameTaken) { + t.Fatalf("expected ErrNameTaken, got %v", err) + } +} + +func TestGetRepoNotFound(t *testing.T) { + s, _, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + if _, err := s.GetRepoByOwnerAndName(ctx, "nobody", "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } + if _, err := s.GetRepoByID(ctx, 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestUpdateAndDeleteRepo(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + insertUser(t, db, 1, "alice", core.UserTypeUser) + repo := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) + + if err := s.UpdateRepo(ctx, repo.ID, "now with docs", core.VisibilityPrivate); err != nil { + t.Fatalf("update: %v", err) + } + got, err := s.GetRepoByID(ctx, repo.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Description != "now with docs" || got.Visibility != core.VisibilityPrivate { + t.Fatalf("update not reflected: %+v", got) + } + + if err := s.UpdateRepo(ctx, 999, "x", core.VisibilityPublic); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound updating missing repo, got %v", err) + } + + if err := s.DeleteRepo(ctx, repo.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetRepoByID(ctx, repo.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } + if err := s.DeleteRepo(ctx, repo.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound deleting twice, got %v", err) + } +} + +// TestListingVisibility exercises the listing rule: owners and ACL holders see +// everything; everyone else (incl. anonymous) sees only PUBLIC; UNLISTED is +// never listed to non-owners without an ACL. +func TestListingVisibility(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + owner := insertUser(t, db, 1, "alice", core.UserTypeUser) + aclUser := insertUser(t, db, 2, "bob", core.UserTypeUser) + stranger := insertUser(t, db, 3, "carol", core.UserTypeUser) + + mkRepo(t, s, ctx, owner, "alice", "pub", core.VisibilityPublic) + mkRepo(t, s, ctx, owner, "alice", "unl", core.VisibilityUnlisted) + priv := mkRepo(t, s, ctx, owner, "alice", "priv", core.VisibilityPrivate) + + // bob gets RO on the private repo. + if err := s.UpsertACL(ctx, priv.ID, aclUser, core.AccessRO); err != nil { + t.Fatalf("grant acl: %v", err) + } + + names := func(repos []*core.Repo) map[string]bool { + m := map[string]bool{} + for _, r := range repos { + m[r.Name] = true + } + return m + } + + // Owner sees all three. + ownerCaller := &core.Caller{UserID: owner, Username: "alice", UserType: core.UserTypeUser} + got, err := s.ListReposByOwner(ctx, "alice", ownerCaller) + if err != nil { + t.Fatalf("list owner: %v", err) + } + if n := names(got); !n["pub"] || !n["unl"] || !n["priv"] || len(got) != 3 { + t.Fatalf("owner should see all three, got %v", n) + } + + // Anonymous sees only PUBLIC. + got, err = s.ListReposByOwner(ctx, "alice", nil) + if err != nil { + t.Fatalf("list anon: %v", err) + } + if n := names(got); !n["pub"] || n["unl"] || n["priv"] || len(got) != 1 { + t.Fatalf("anon should see only pub, got %v", n) + } + + // Stranger (no ACL) sees only PUBLIC. + strangerCaller := &core.Caller{UserID: stranger, Username: "carol", UserType: core.UserTypeUser} + got, err = s.ListReposByOwner(ctx, "alice", strangerCaller) + if err != nil { + t.Fatalf("list stranger: %v", err) + } + if n := names(got); !n["pub"] || n["unl"] || n["priv"] || len(got) != 1 { + t.Fatalf("stranger should see only pub, got %v", n) + } + + // ACL holder sees PUBLIC plus the private repo they were granted (not UNLISTED). + aclCaller := &core.Caller{UserID: aclUser, Username: "bob", UserType: core.UserTypeUser} + got, err = s.ListReposByOwner(ctx, "alice", aclCaller) + if err != nil { + t.Fatalf("list acl user: %v", err) + } + if n := names(got); !n["pub"] || !n["priv"] || n["unl"] || len(got) != 2 { + t.Fatalf("acl user should see pub+priv, got %v", n) + } +} + +func TestListReposForDashboard(t *testing.T) { + s, db, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + owner := insertUser(t, db, 1, "alice", core.UserTypeUser) + other := insertUser(t, db, 2, "bob", core.UserTypeUser) + + own := mkRepo(t, s, ctx, owner, "alice", "mine", core.VisibilityPrivate) + shared := mkRepo(t, s, ctx, other, "bob", "shared", core.VisibilityPrivate) + mkRepo(t, s, ctx, other, "bob", "hidden", core.VisibilityPrivate) + + if err := s.UpsertACL(ctx, shared.ID, owner, core.AccessRW); err != nil { + t.Fatalf("grant: %v", err) + } + + got, err := s.ListReposForDashboard(ctx, owner) + if err != nil { + t.Fatalf("dashboard: %v", err) + } + found := map[string]bool{} + for _, r := range got { + found[r.Name] = true + } + if !found["mine"] || !found["shared"] || found["hidden"] || len(got) != 2 { + t.Fatalf("dashboard should list own+acl only, got %v", found) + } + _ = own +} diff --git a/db/store.go b/db/store.go new file mode 100644 index 0000000000000000000000000000000000000000..a1bf4f3426b76dd9a46ff2b3e26f878981f191fe --- /dev/null +++ b/db/store.go @@ -0,0 +1,84 @@ +// Package db is the PostgreSQL persistence layer for dolt.sr.ht. It maps the +// repository, access (ACL) and dolt_key tables to core value types with plain +// database/sql and $n placeholders (no ORM), and enforces the listing/effective +// -access rules that the pure core.Allowed matrix cannot express in SQL. +// +// Design: a Store wraps a Querier — an interface satisfied by *sql.DB, *sql.Tx +// and *sql.Conn alike. This gives us two things the task requires at once: +// +// - Context-first, middleware-compatible signatures. Production callers build +// a Store from the connection that core-go's database middleware injects +// into the request context (see FromContext, which reads the same *sql.DB +// that database.Middleware installed). Every method takes ctx first and +// threads it into the query for cancellation. +// +// - Trivial test injection. Tests call NewStore(db) with a plain *sql.DB, no +// HTTP context required. +// +// Because the wrapped value is an interface, a Store can be re-bound to an +// open transaction with WithTx(tx): the multi-statement create-repo flow (INSERT +// the row, then write the on-disk NBS store, rolling back both on failure) runs +// every query on the caller's *sql.Tx while sharing the exact same method set. +package db + +import ( + "context" + "database/sql" + "errors" + + "git.sr.ht/~sircmpwn/core-go/database" +) + +// Querier is the common subset of *sql.DB, *sql.Tx and *sql.Conn used by this +// package. Binding a Store to any of them keeps every method identical whether +// it runs standalone (autocommit) or inside a caller-managed transaction. +type Querier interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// Store is the entry point for all queries in this package. +type Store struct { + q Querier +} + +// NewStore builds a Store over a database handle (or any Querier). Tests pass a +// plain *sql.DB; production wiring passes the shared pool. +func NewStore(q Querier) *Store { + return &Store{q: q} +} + +// FromContext builds a Store over the *sql.DB that core-go's database.Middleware +// installed in ctx. It panics (via database.DBForContext) if no database is +// present in the context — a programming error, never a runtime condition to +// recover from. We wrap the pooled *sql.DB rather than checking out a *sql.Conn +// (database.ForContext) so the Store has no connection to leak; the pool manages +// connection lifetime and ctx still bounds each query. +func FromContext(ctx context.Context) *Store { + return &Store{q: database.DBForContext(ctx)} +} + +// WithTx returns a Store that runs every query on tx instead of the pool. Used +// by the repository create flow, which must coordinate the SQL insert with the +// on-disk store creation under one transaction. +func (s *Store) WithTx(tx *sql.Tx) *Store { + return &Store{q: tx} +} + +// Typed errors returned by this package. Callers match them with errors.Is. +var ( + // ErrNotFound is returned when a lookup, update or delete matched no row. + ErrNotFound = errors.New("db: not found") + // ErrNameTaken is returned by CreateRepo when the owner already has a + // repository with the requested name (uq_repo_owner_id_name violation). + ErrNameTaken = errors.New("db: repository name already taken") + // ErrKeyExists is returned by InsertKey when the key id (kid) is already + // registered (dolt_key.kid UNIQUE violation). + ErrKeyExists = errors.New("db: dolt key already registered") +) + +// rowScanner is satisfied by both *sql.Row and *sql.Rows. +type rowScanner interface { + Scan(dest ...any) error +}