~bigbes/sr-ht-dolt

2b781a27e6fd7eaa68ee4e692fe0da7348569daf — Eugene Blikh 30 days ago 6b1c9b7
remoteapi: authz interceptors, remotesrv assembly, credentials service
M authn/backend.go => authn/backend.go +13 -0
@@ 52,6 52,19 @@ func (coreMetaBackend) IsRevoked(ctx context.Context, username string, hash [64]
// meta.sr.ht implementation; tests reassign it (white-box) and restore it.
var meta MetaBackend = coreMetaBackend{}

// SetMetaBackend swaps the package-level meta backend used by the resolvers and
// returns a function that restores the previous one. It is a wiring/test seam:
// integration tests living in OTHER packages (e.g. remoteapi) need to inject an
// in-memory MetaBackend so they can exercise the full auth stack without a live
// meta.sr.ht or the internal-network trust it requires. Production code never
// calls it, and it is not safe for concurrent use — a test installs a backend,
// runs, and restores it via the returned func (typically with t.Cleanup).
func SetMetaBackend(b MetaBackend) (restore func()) {
	prev := meta
	meta = b
	return func() { meta = prev }
}

// equalUsername reports whether two SourceHut usernames refer to the same user,
// ignoring a leading "~" (the canonical-name sigil) and ASCII case.
func equalUsername(a, b string) bool {

A remoteapi/credsvc.go => remoteapi/credsvc.go +141 -0
@@ 0,0 1,141 @@
package remoteapi

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"net"
	"strings"

	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/database"
	remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	"go.bigb.es/sourcehut-dolt/authn"
)

// credService implements remotesapi.CredentialsServiceServer.WhoAmI. `dolt
// login` polls WhoAmI with the user's keypair Bearer JWT until the key is
// associated with a SourceHut account through the web UI; once association
// happens, the JWT verifies and WhoAmI returns the account identity, which the
// CLI prints as confirmation.
type credService struct {
	remotesapi.UnimplementedCredentialsServiceServer

	conf        ini.File
	service     string
	expectedAud string
	pool        *sql.DB
	keys        authn.KeyStore
	logger      *logrus.Entry
}

// WhoAmI verifies the request's Bearer keypair JWT (exactly as the remotesapi
// interceptors do) and returns the owning SourceHut user's identity. It is
// keypair-only: a missing or non-Bearer authorization header, or an invalid
// token, is Unauthenticated — the state the CLI polls through until the web UI
// associates the key. A transient backend failure is Unavailable.
func (c *credService) WhoAmI(ctx context.Context, _ *remotesapi.WhoAmIRequest) (*remotesapi.WhoAmIResponse, error) {
	ctx = database.Context(config.Context(ctx, c.conf, c.service), c.pool)

	token, err := bearerToken(ctx)
	if err != nil {
		return nil, err
	}

	ac, err := authn.ResolveDoltJWT(ctx, token, c.expectedAud, c.keys)
	if err != nil {
		if errors.Is(err, authn.ErrInvalidToken) {
			return nil, status.Error(codes.Unauthenticated, "invalid or expired credentials")
		}
		c.logger.Errorf("credentials WhoAmI backend error: %v", err)
		return nil, status.Error(codes.Unavailable, "authentication temporarily unavailable")
	}

	// The meta mirror carries no separate display name, so we surface the
	// username for DisplayName; EmailAddress comes from the mirrored profile.
	return &remotesapi.WhoAmIResponse{
		Username:     ac.Username,
		DisplayName:  ac.Username,
		EmailAddress: ac.Email,
	}, nil
}

// bearerToken extracts the raw JWT from an incoming "authorization: Bearer <jwt>"
// metadata header. A missing header or non-Bearer scheme is Unauthenticated.
func bearerToken(ctx context.Context) (string, error) {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return "", status.Error(codes.Unauthenticated, "missing credentials")
	}
	vals := md.Get("authorization")
	if len(vals) == 0 || vals[0] == "" {
		return "", status.Error(codes.Unauthenticated, "missing credentials")
	}
	scheme, value, found := strings.Cut(vals[0], " ")
	if !found || !strings.EqualFold(scheme, "bearer") || value == "" {
		return "", status.Error(codes.Unauthenticated, "expected a Bearer keypair token")
	}
	return value, nil
}

// CredServer is the small standalone gRPC server hosting CredentialsService on
// its own port (nginx path-routes it separately from the chunk-store server).
type CredServer struct {
	grpc   *grpc.Server
	addr   string
	logger *logrus.Entry
}

// NewCredServer assembles the CredentialsService server. It shares the keystore
// derivation and audience normalization with the chunk-store server so both
// verify keypair JWTs identically.
func NewCredServer(cfg Config) (*CredServer, error) {
	if cfg.DB == nil {
		return nil, fmt.Errorf("remoteapi: NewCredServer requires a non-nil DB")
	}
	if cfg.CredsListenAddr == "" {
		return nil, fmt.Errorf("remoteapi: NewCredServer requires a CredsListenAddr")
	}
	logger := cfg.Logger
	if logger == nil {
		logger = logrus.NewEntry(logrus.StandardLogger())
	}

	svc := &credService{
		conf:        cfg.Conf,
		service:     serviceName,
		expectedAud: normalizeAud(cfg.HttpHost),
		pool:        cfg.DB,
		keys:        newKeyStore(cfg.DB),
		logger:      logger,
	}

	gsrv := grpc.NewServer()
	remotesapi.RegisterCredentialsServiceServer(gsrv, svc)

	return &CredServer{grpc: gsrv, addr: cfg.CredsListenAddr, logger: logger}, nil
}

// Serve binds the listener and serves until GracefulStop. It blocks. It returns
// an error if binding fails or the gRPC server exits with one.
func (s *CredServer) Serve() error {
	lis, err := net.Listen("tcp", s.addr)
	if err != nil {
		return fmt.Errorf("remoteapi: bind credentials %q: %w", s.addr, err)
	}
	if err := s.grpc.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
		return fmt.Errorf("remoteapi: credentials serve: %w", err)
	}
	return nil
}

// GracefulStop stops the credentials server.
func (s *CredServer) GracefulStop() { s.grpc.GracefulStop() }

A remoteapi/integration_test.go => remoteapi/integration_test.go +503 -0
@@ 0,0 1,503 @@
//go:build integration

// Integration test for the full remotesapi stack: real Postgres (Docker), the
// real dolt CLI (v2.1.10) driving clone/push over our auth interceptors, and the
// CredentialsService.WhoAmI RPC. It exercises every auth path end to end:
//
//	(1) anonymous clone of a PUBLIC db succeeds;
//	(2) anonymous push is denied;
//	(3) Basic auth (--user + DOLT_REMOTE_PASSWORD, forged PAT) push to own repo;
//	(4) PRIVATE anonymous clone fails "not found";
//	(5) keypair (Bearer EdDSA JWT) clone of a PRIVATE own db succeeds after the
//	    key is registered (dolt creds flow, no --user);
//	(6) WhoAmI answers with the account identity for a keypair JWT;
//	(7) an ACL RO grantee can clone but not push (private repo stays "found").
//
// Run with:
//
//	CGO_CPPFLAGS=-I/opt/homebrew/opt/icu4c@78/include \
//	CGO_LDFLAGS=-L/opt/homebrew/opt/icu4c@78/lib \
//	go test -tags integration -mod=readonly ./remoteapi/ -run TestRemoteAPIIntegration -v
//
// It skips cleanly when the dolt CLI or Docker (and DOLTSRHT_TEST_PG) are absent.
package remoteapi

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"git.sr.ht/~sircmpwn/core-go/crypto"
	remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
	"github.com/fernet/fernet-go"
	_ "github.com/lib/pq"
	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	"go.bigb.es/sourcehut-dolt/authn"
	"go.bigb.es/sourcehut-dolt/core"
	"go.bigb.es/sourcehut-dolt/db"
	"go.bigb.es/sourcehut-dolt/storage"
)

const doltBin = "/opt/homebrew/bin/dolt"

// stubMeta is an in-memory MetaBackend: it mirrors a fixed user table and never
// reports a token revoked. It lets the full auth stack run without a live
// meta.sr.ht or the internal-network trust it needs.
type stubMeta struct {
	users map[string]auth.AuthContext
}

func (s *stubMeta) LookupUser(_ context.Context, username string, out *auth.AuthContext) error {
	u, ok := s.users[strings.ToLower(strings.TrimPrefix(username, "~"))]
	if !ok {
		return fmt.Errorf("stubMeta: unknown user %q", username)
	}
	*out = u
	return nil
}

func (s *stubMeta) IsRevoked(_ context.Context, _ string, _ [64]byte, _ string) (bool, error) {
	return false, nil
}

func TestRemoteAPIIntegration(t *testing.T) {
	if _, err := os.Stat(doltBin); err != nil {
		t.Skipf("dolt CLI not found at %s (%v); skipping integration test", doltBin, err)
	}

	dsn, stopPG := startPostgres(t)
	defer stopPG()

	pool, err := sql.Open("postgres", dsn)
	if err != nil {
		t.Fatalf("open pool: %v", err)
	}
	defer pool.Close()
	waitPG(t, pool)
	applySchema(t, pool)

	// Shared crypto keyset (fernet network key + ed25519 webhooks seed) so forged
	// PATs validate; the same ini is threaded into the server as its config.
	conf := synthConf(t)
	crypto.InitCrypto(conf)

	// Install the in-memory meta backend for the duration of the test.
	restore := authn.SetMetaBackend(&stubMeta{users: map[string]auth.AuthContext{
		"alice": {UserID: 1, Username: "alice", Email: "alice@example.test", UserType: auth.USER_TYPE_USER},
		"bob":   {UserID: 2, Username: "bob", Email: "bob@example.test", UserType: auth.USER_TYPE_USER},
	}})
	defer restore()

	ctx := context.Background()
	store := db.NewStore(pool)

	insertUser(t, pool, 1, "alice", core.UserTypeUser)
	insertUser(t, pool, 2, "bob", core.UserTypeUser)

	reposRoot := filepath.Join(t.TempDir(), "repos")
	if err := os.MkdirAll(reposRoot, 0o755); err != nil {
		t.Fatal(err)
	}

	pubRepo := mkRepo(t, ctx, store, reposRoot, 1, "alice", "pubdb", core.VisibilityPublic)
	privRepo := mkRepo(t, ctx, store, reposRoot, 1, "alice", "privdb", core.VisibilityPrivate)
	// A separate private repo that bob will hold an RO ACL on (scenario 7).
	roRepo := mkRepo(t, ctx, store, reposRoot, 1, "alice", "rodb", core.VisibilityPrivate)
	if err := store.UpsertACL(ctx, roRepo.ID, 2, core.AccessRO); err != nil {
		t.Fatalf("grant bob RO on rodb: %v", err)
	}
	_ = pubRepo
	_ = privRepo

	// Start both servers on ephemeral ports. HttpHost is the remotesapi listen
	// addr so chunk URLs carry the right host:port AND the derived JWT audience
	// (the bare host) matches what the CLI sends.
	addr := freeAddr(t)
	credAddr := freeAddr(t)
	logger := logrus.NewEntry(logrus.New())
	logger.Logger.SetLevel(logrus.ErrorLevel)

	cfg := Config{
		Conf:            conf,
		DB:              pool,
		ReposRoot:       reposRoot,
		ListenAddr:      addr,
		CredsListenAddr: credAddr,
		HttpHost:        addr,
		Logger:          logger,
	}
	srv, err := New(cfg)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	go func() {
		if err := srv.Serve(); err != nil {
			logger.Errorf("remotesapi serve: %v", err)
		}
	}()
	defer srv.GracefulStop()

	csrv, err := NewCredServer(cfg)
	if err != nil {
		t.Fatalf("NewCredServer: %v", err)
	}
	go func() {
		if err := csrv.Serve(); err != nil {
			logger.Errorf("credentials serve: %v", err)
		}
	}()
	defer csrv.GracefulStop()

	waitTCP(t, addr)
	waitTCP(t, credAddr)

	// Scratch HOME so the developer's real dolt config is untouched.
	home := filepath.Join(t.TempDir(), "home")
	if err := os.MkdirAll(home, 0o755); err != nil {
		t.Fatal(err)
	}
	baseEnv := append(os.Environ(), "HOME="+home)
	runDolt(t, baseEnv, home, "config", "--global", "--add", "user.email", "alice@example.test")
	runDolt(t, baseEnv, home, "config", "--global", "--add", "user.name", "alice")

	pubURL := fmt.Sprintf("http://%s/~alice/pubdb", addr)
	privURL := fmt.Sprintf("http://%s/~alice/privdb", addr)
	roURL := fmt.Sprintf("http://%s/~alice/rodb", addr)

	pat := forgePAT("alice", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour))

	// (1) Anonymous clone of PUBLIC succeeds.
	t.Run("anonymous_public_clone", func(t *testing.T) {
		dir := t.TempDir()
		runDolt(t, baseEnv, dir, "clone", pubURL)
		if _, err := os.Stat(filepath.Join(dir, "pubdb", ".dolt")); err != nil {
			t.Fatalf("clone did not produce a working dir: %v", err)
		}
	})

	// (2) Anonymous push is denied.
	t.Run("anonymous_push_denied", func(t *testing.T) {
		dir := t.TempDir()
		runDolt(t, baseEnv, dir, "clone", pubURL)
		work := filepath.Join(dir, "pubdb")
		runDolt(t, baseEnv, work, "sql", "-q", "create table t(i int primary key); insert into t values (1);")
		runDolt(t, baseEnv, work, "commit", "-Am", "anon change")
		out, err := tryDolt(baseEnv, work, "push", "origin", "main")
		if err == nil {
			t.Fatalf("anonymous push unexpectedly succeeded:\n%s", out)
		}
		t.Logf("anonymous push correctly denied:\n%s", out)
	})

	// (3) Basic auth (forged PAT) push to own repo succeeds.
	t.Run("basic_auth_push", func(t *testing.T) {
		dir := t.TempDir()
		env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+pat)
		runDolt(t, env, dir, "clone", "--user", "alice", pubURL)
		work := filepath.Join(dir, "pubdb")
		runDolt(t, env, work, "sql", "-q", "create table basic(i int primary key); insert into basic values (7);")
		runDolt(t, env, work, "commit", "-Am", "basic push")
		runDolt(t, env, work, "push", "--user", "alice", "origin", "main")

		// Re-clone anonymously and verify the pushed row landed.
		dir2 := t.TempDir()
		runDolt(t, baseEnv, dir2, "clone", pubURL)
		out := runDolt(t, baseEnv, filepath.Join(dir2, "pubdb"), "sql", "-q", "select i from basic", "-r", "csv")
		if !strings.Contains(out, "7") {
			t.Fatalf("pushed row not present after re-clone; got:\n%s", out)
		}
	})

	// (4) PRIVATE anonymous clone fails "not found".
	t.Run("private_anonymous_notfound", func(t *testing.T) {
		dir := t.TempDir()
		out, err := tryDolt(baseEnv, dir, "clone", privURL)
		if err == nil {
			t.Fatalf("anonymous clone of PRIVATE unexpectedly succeeded:\n%s", out)
		}
		if !strings.Contains(strings.ToLower(out), "not found") {
			t.Fatalf("expected a not-found style failure, got:\n%s", out)
		}
		t.Logf("private anonymous clone correctly not found:\n%s", out)
	})

	// (5) Keypair (Bearer JWT) clone of PRIVATE own db succeeds. Mint a keypair,
	// register it for alice, then clone with no --user so the CLI uses the JWK.
	t.Run("keypair_private_clone", func(t *testing.T) {
		dc, err := creds.GenerateCredentials()
		if err != nil {
			t.Fatalf("generate creds: %v", err)
		}
		kid := dc.KeyIDBase32Str()
		if _, err := store.InsertKey(ctx, 1, kid, dc.PubKey, "it-key"); err != nil {
			t.Fatalf("register dolt key: %v", err)
		}
		writeJWK(t, home, dc)
		runDolt(t, baseEnv, home, "config", "--global", "--add", "user.creds", kid)

		dir := t.TempDir()
		runDolt(t, baseEnv, dir, "clone", privURL)
		if _, err := os.Stat(filepath.Join(dir, "privdb", ".dolt")); err != nil {
			t.Fatalf("keypair clone of PRIVATE did not produce a working dir: %v", err)
		}
		t.Log("keypair clone of PRIVATE own db succeeded")

		// Unregister user.creds so later CLI runs are anonymous again.
		runDolt(t, baseEnv, home, "config", "--global", "--unset", "user.creds")
	})

	// (6) WhoAmI answers with alice's identity for a keypair JWT.
	t.Run("whoami", func(t *testing.T) {
		dc, err := creds.GenerateCredentials()
		if err != nil {
			t.Fatalf("generate creds: %v", err)
		}
		kid := dc.KeyIDBase32Str()
		if _, err := store.InsertKey(ctx, 1, kid, dc.PubKey, "whoami-key"); err != nil {
			t.Fatalf("register dolt key: %v", err)
		}

		conn, err := grpc.NewClient(credAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
		if err != nil {
			t.Fatalf("dial credentials: %v", err)
		}
		defer conn.Close()
		client := remotesapi.NewCredentialsServiceClient(conn)

		// aud = the bare host the CLI derives from the endpoint (host:port).
		host, _, _ := net.SplitHostPort(credAddr)
		rpc := dc.RPCCreds(host)
		reqMD, err := rpc.GetRequestMetadata(ctx)
		if err != nil {
			t.Fatalf("mint JWT: %v", err)
		}
		outCtx := metadata.AppendToOutgoingContext(ctx, "authorization", reqMD["authorization"])
		resp, err := client.WhoAmI(outCtx, &remotesapi.WhoAmIRequest{})
		if err != nil {
			t.Fatalf("WhoAmI: %v", err)
		}
		if resp.Username != "alice" || resp.EmailAddress != "alice@example.test" {
			t.Fatalf("WhoAmI returned %+v, want alice/alice@example.test", resp)
		}

		// A request with no token is Unauthenticated.
		if _, err := client.WhoAmI(ctx, &remotesapi.WhoAmIRequest{}); status.Code(err) != codes.Unauthenticated {
			t.Fatalf("unauthenticated WhoAmI: want Unauthenticated, got %v", err)
		}
	})

	// (7) ACL RO grantee (bob) can clone the private rodb but cannot push.
	t.Run("acl_ro_clone_not_push", func(t *testing.T) {
		bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour))
		env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT)

		dir := t.TempDir()
		runDolt(t, env, dir, "clone", "--user", "bob", roURL)
		work := filepath.Join(dir, "rodb")
		runDolt(t, env, work, "sql", "-q", "create table t(i int primary key); insert into t values (1);")
		runDolt(t, env, work, "commit", "-Am", "bob change")
		out, err := tryDolt(env, work, "push", "--user", "bob", "origin", "main")
		if err == nil {
			t.Fatalf("RO grantee push unexpectedly succeeded:\n%s", out)
		}
		t.Logf("RO grantee push correctly denied:\n%s", out)
	})
}

// --- helpers ---

func synthConf(t *testing.T) ini.File {
	t.Helper()
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		t.Fatal(err)
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		t.Fatal(err)
	}
	return ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	}
}

func forgePAT(username, grants string, expires time.Time) string {
	bt := auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		Grants:   grants,
		Username: username,
	}
	return bt.Encode()
}

func writeJWK(t *testing.T, home string, dc creds.DoltCreds) {
	t.Helper()
	dir := filepath.Join(home, ".dolt", "creds")
	if err := os.MkdirAll(dir, 0o700); err != nil {
		t.Fatal(err)
	}
	data, err := creds.JWKCredSerialize(dc)
	if err != nil {
		t.Fatalf("serialize JWK: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, dc.KeyIDBase32Str()+".jwk"), data, 0o600); err != nil {
		t.Fatalf("write JWK: %v", err)
	}
}

func mkRepo(t *testing.T, ctx context.Context, store *db.Store, reposRoot string, ownerID int, owner, name string, vis core.Visibility) *core.Repo {
	t.Helper()
	abs := storage.RepoDiskPath(reposRoot, owner, name)
	if err := storage.InitStore(ctx, abs, owner, owner+"@example.test"); err != nil {
		t.Fatalf("init store %s: %v", name, err)
	}
	repo, err := store.CreateRepo(ctx, &core.Repo{
		Name: name, OwnerID: ownerID, OwnerName: owner, Path: abs, Visibility: vis,
	})
	if err != nil {
		t.Fatalf("create repo %s: %v", name, err)
	}
	return repo
}

func insertUser(t *testing.T, pool *sql.DB, id int, username string, ut core.UserType) {
	t.Helper()
	now := time.Now().UTC()
	_, err := pool.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)
	}
}

// startPostgres returns a DSN for a Postgres to test against. It prefers an
// existing DOLTSRHT_TEST_PG DSN; otherwise it spins postgres:16-alpine via
// Docker. The test skips cleanly if neither is available.
func startPostgres(t *testing.T) (dsn string, cleanup func()) {
	t.Helper()
	if env := os.Getenv("DOLTSRHT_TEST_PG"); env != "" {
		return env, func() {}
	}
	if _, err := exec.LookPath("docker"); err != nil {
		t.Skip("neither DOLTSRHT_TEST_PG nor docker available; skipping integration test")
	}
	port := freePort(t)
	name := "doltsrht-it-" + randToken()
	out, err := exec.Command("docker", "run", "-d", "--name", name,
		"-e", "POSTGRES_PASSWORD=postgres", "-e", "POSTGRES_DB=doltsrht",
		"-p", fmt.Sprintf("127.0.0.1:%d:5432", port), "postgres:16-alpine").CombinedOutput()
	if err != nil {
		t.Skipf("docker run postgres failed (%v):\n%s", err, out)
	}
	cleanup = func() {
		if o, err := exec.Command("docker", "rm", "-f", name).CombinedOutput(); err != nil {
			t.Logf("docker rm -f %s: %v\n%s", name, err, o)
		}
	}
	return fmt.Sprintf("postgres://postgres:postgres@127.0.0.1:%d/doltsrht?sslmode=disable", port), cleanup
}

func waitPG(t *testing.T, pool *sql.DB) {
	t.Helper()
	deadline := time.Now().Add(60 * time.Second)
	for time.Now().Before(deadline) {
		if err := pool.Ping(); err == nil {
			return
		}
		time.Sleep(500 * time.Millisecond)
	}
	t.Fatal("postgres never became ready")
}

func applySchema(t *testing.T, pool *sql.DB) {
	t.Helper()
	ddl, err := os.ReadFile("../schema.sql")
	if err != nil {
		t.Fatalf("read schema.sql: %v", err)
	}
	if _, err := pool.Exec(string(ddl)); err != nil {
		t.Fatalf("apply schema.sql: %v", err)
	}
}

func freePort(t *testing.T) int {
	t.Helper()
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("probe listen: %v", err)
	}
	defer l.Close()
	return l.Addr().(*net.TCPAddr).Port
}

func freeAddr(t *testing.T) string {
	t.Helper()
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("probe listen: %v", err)
	}
	addr := l.Addr().String()
	l.Close()
	return addr
}

func waitTCP(t *testing.T, addr string) {
	t.Helper()
	deadline := time.Now().Add(10 * time.Second)
	for time.Now().Before(deadline) {
		conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
		if err == nil {
			conn.Close()
			return
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Fatalf("server at %s never became reachable", addr)
}

func runDolt(t *testing.T, env []string, dir string, args ...string) string {
	t.Helper()
	out, err := tryDolt(env, dir, args...)
	if err != nil {
		t.Fatalf("dolt %s failed: %v\n%s", strings.Join(args, " "), err, out)
	}
	return out
}

func tryDolt(env []string, dir string, args ...string) (string, error) {
	cmd := exec.Command(doltBin, args...)
	cmd.Dir = dir
	cmd.Env = env
	out, err := cmd.CombinedOutput()
	return string(out), err
}

func randToken() string {
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

A remoteapi/interceptors.go => remoteapi/interceptors.go +330 -0
@@ 0,0 1,330 @@
package remoteapi

import (
	"context"
	"database/sql"
	"errors"
	"fmt"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/database"
	remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	"go.bigb.es/sourcehut-dolt/authn"
	"go.bigb.es/sourcehut-dolt/core"
	"go.bigb.es/sourcehut-dolt/db"
)

// Method-classification sets, copied verbatim from upstream remotesrv's
// interceptors.go (SUPER_USER_RPC_METHODS / CLONE_ADMIN_RPC_METHODS). Upstream
// drops the authenticated context and never sees the repo path, so it can only
// make a binary superuser/clone-admin decision; we keep its exact method lists
// but layer our own per-repo ACL check on top (see authorize).
//
//   - writeMethods are pushes: they require OpPush / AccessRW.
//   - readMethods are clone/pull/fetch reads: OpCloneRead / AccessRO.
//   - anything else is an unknown method and is denied (PermissionDenied),
//     matching upstream's "unknown rpc method" hard failure.
var (
	writeMethods = map[string]bool{
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/AddTableFiles":      true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Commit":             true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetUploadLocations": true,
	}
	readMethods = map[string]bool{
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetDownloadLocations":    true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetRepoMetadata":         true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/HasChunks":               true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/ListTableFiles":          true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/RefreshTableFileUrl":     true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Root":                    true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/StreamDownloadLocations": true,
		"/dolt.services.remotesapi.v1alpha1.ChunkStoreService/StreamChunkLocations":    true,
	}

	// rootMethod is the only read RPC the dolt client may send with no repo path
	// (a bare "what is the current root" ping used during dial/handshake). It is
	// treated as an unauthenticated-OK ping: it still authenticates the caller
	// (so a bad token is rejected) but skips the per-repo ACL check when no path
	// is present. Every real read carries a repo path and is checked normally.
	rootMethod = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Root"
)

// repoRequest is the subset of every ChunkStoreService request message that
// carries the target repository, mirrored from upstream remotesrv's private
// repoRequest interface. All request types implement it.
type repoRequest interface {
	GetRepoId() *remotesapi.RepoId
	GetRepoPath() string
}

// repoStore is the narrow slice of db.Store the interceptor needs. Declaring it
// locally (rather than depending on *db.Store directly) keeps the authorization
// logic unit-testable with an in-memory stub — no Postgres required.
type repoStore interface {
	GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error)
	EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error)
}

// interceptor holds the collaborators the per-RPC auth/authz decision needs. It
// is installed on the remotesrv gRPC server via Options().
type interceptor struct {
	conf        ini.File
	service     string
	expectedAud string
	keys        authn.KeyStore
	logger      *logrus.Entry

	// pool is the shared database pool, threaded into the request context via
	// database.Context so db.FromContext-style lookups and the token resolvers
	// can reach it.
	pool *sql.DB

	// stores returns a repoStore for a request. Production binds a fresh
	// db.Store to the shared pool; tests inject a stub.
	stores func() repoStore
}

// newInterceptor builds the interceptor over the shared pool. It binds a fresh
// db.Store per request (the pool owns connection lifetime, ctx bounds each
// query). pool and keys must be non-nil.
func newInterceptor(conf ini.File, service, expectedAud string, pool *sql.DB, keys authn.KeyStore, logger *logrus.Entry) *interceptor {
	if pool == nil {
		panic("remoteapi: newInterceptor requires a non-nil *sql.DB")
	}
	if logger == nil {
		logger = logrus.NewEntry(logrus.StandardLogger())
	}
	i := &interceptor{
		conf:        conf,
		service:     service,
		expectedAud: expectedAud,
		keys:        keys,
		logger:      logger,
		pool:        pool,
	}
	i.stores = func() repoStore { return db.NewStore(pool) }
	return i
}

// dbHandle returns the shared pool threaded into request contexts.
func (i *interceptor) dbHandle() *sql.DB { return i.pool }

// Options returns the gRPC server options that install both interceptors,
// matching upstream ServerInterceptor.Options.
func (i *interceptor) Options() []grpc.ServerOption {
	return []grpc.ServerOption{
		grpc.ChainUnaryInterceptor(i.unary()),
		grpc.ChainStreamInterceptor(i.stream()),
	}
}

// classify maps a full gRPC method to its access op/mode. ok is false for an
// unknown method, which the caller denies.
func classify(fullMethod string) (op core.Op, mode core.AccessMode, ok bool) {
	if writeMethods[fullMethod] {
		return core.OpPush, core.AccessRW, true
	}
	if readMethods[fullMethod] {
		return core.OpCloneRead, core.AccessRO, true
	}
	return 0, "", false
}

// withServiceCtx augments the live per-RPC context (which carries the gRPC
// deadline, cancellation and incoming metadata) with the config and database
// values the authn resolvers and db.Store read from context. We augment the
// handler's context rather than starting from a stored base context so request
// deadlines and the incoming "authorization" metadata are preserved.
func (i *interceptor) withServiceCtx(ctx context.Context) context.Context {
	ctx = config.Context(ctx, i.conf, i.service)
	ctx = database.Context(ctx, i.dbHandle())
	return ctx
}

// authenticate resolves the caller from the incoming "authorization" metadata.
// It returns the caller (nil for an anonymous request) or a gRPC status error:
// Unauthenticated for a bad/forged/revoked credential, Unavailable for a
// transient backend failure (meta.sr.ht or the database unreachable).
func (i *interceptor) authenticate(ctx context.Context) (*auth.AuthContext, error) {
	header := ""
	if md, ok := metadata.FromIncomingContext(ctx); ok {
		if vals := md.Get("authorization"); len(vals) > 0 {
			header = vals[0]
		}
	}
	ac, err := authn.ResolveGRPCAuth(ctx, header, i.expectedAud, i.keys)
	if err != nil {
		if errors.Is(err, authn.ErrInvalidToken) {
			i.logger.Warnf("remotesapi authentication rejected: %v", err)
			return nil, status.Error(codes.Unauthenticated, "invalid or expired credentials")
		}
		// Transient: meta.sr.ht or the database is unreachable. Never surface as a
		// hard credential rejection — the client should retry.
		i.logger.Errorf("remotesapi authentication backend error: %v", err)
		return nil, status.Error(codes.Unavailable, "authentication temporarily unavailable")
	}
	return ac, nil
}

// authorize applies the grant gate and the per-repo ACL to an authenticated
// caller and returns a context carrying the resolved caller for the handler, or
// a gRPC status error. It is called once per unary request and once per stream
// message. The order mirrors the plan:
//
//  1. classify the method (unknown ⇒ PermissionDenied);
//  2. OAuth grant gate (TokenGrantsAllow) — PAT callers must carry
//     dolt.sr.ht/repos:RO for reads / :RW for pushes; anonymous, cookie and
//     dolt-key callers pass trivially;
//  3. extract the repo path (Root with no path ⇒ unauthenticated-OK ping);
//  4. load the repository row and the caller's effective ACL;
//  5. core.Allowed — on denial, NotFound for a PRIVATE repo the caller cannot
//     even see (no existence leak), PermissionDenied for a visible repo.
func (i *interceptor) authorize(ctx context.Context, ac *auth.AuthContext, fullMethod string, req any) (context.Context, error) {
	op, mode, ok := classify(fullMethod)
	if !ok {
		return nil, status.Errorf(codes.PermissionDenied, "unknown rpc method: %s", fullMethod)
	}

	// Grant gate is a global property of the token (independent of any repo), so
	// running it first leaks nothing about repository existence.
	if !authn.TokenGrantsAllow(ac, mode) {
		return nil, status.Errorf(codes.PermissionDenied,
			"token grants do not permit %s on %s repositories", mode, i.service)
	}

	rr, isRepoReq := req.(repoRequest)
	repoPath := ""
	if isRepoReq {
		repoPath = repoPathOf(rr)
	}
	if repoPath == "" {
		if fullMethod == rootMethod {
			// Handshake ping with no repo: authenticated but not repo-scoped.
			return authn.WithCaller(ctx, ac), nil
		}
		return nil, status.Error(codes.InvalidArgument, "request is missing a repository path")
	}

	owner, name, err := core.ParseRepoPath(repoPath)
	if err != nil {
		return nil, status.Errorf(codes.InvalidArgument, "invalid repository path %q: %v", repoPath, err)
	}

	store := i.stores()
	repo, err := store.GetRepoByOwnerAndName(ctx, owner, name)
	if err != nil {
		if errors.Is(err, db.ErrNotFound) {
			return nil, status.Errorf(codes.NotFound, "repository %s/%s not found", owner, name)
		}
		i.logger.Errorf("remotesapi repo lookup %s/%s: %v", owner, name, err)
		return nil, status.Error(codes.Unavailable, "repository lookup temporarily unavailable")
	}

	caller := authn.AsCoreCaller(ac)
	var aclMode *core.AccessMode
	if caller != nil {
		aclMode, err = store.EffectiveAccess(ctx, caller.UserID, repo.ID)
		if err != nil {
			i.logger.Errorf("remotesapi effective-access user=%d repo=%d: %v", caller.UserID, repo.ID, err)
			return nil, status.Error(codes.Unavailable, "authorization temporarily unavailable")
		}
	}

	if !core.Allowed(caller, repo, aclMode, op) {
		if core.NotFoundForPrivate(caller, repo, aclMode) {
			return nil, status.Errorf(codes.NotFound, "repository %s/%s not found", owner, name)
		}
		return nil, status.Errorf(codes.PermissionDenied, "%s denied on %s/%s", op, owner, name)
	}

	return authn.WithCaller(ctx, ac), nil
}

// repoPathOf extracts the target repo path from a request without panicking on
// an absent path (upstream's getRepoPath panics on empty path + nil repo id).
// It returns "" when neither is present so the caller can treat Root specially.
func repoPathOf(req repoRequest) string {
	if p := req.GetRepoPath(); p != "" {
		return p
	}
	if id := req.GetRepoId(); id != nil {
		return fmt.Sprintf("%s/%s", id.Org, id.RepoName)
	}
	return ""
}

// unary is the unary server interceptor: authenticate once, authorize the
// request, then invoke the handler with the caller-carrying context.
func (i *interceptor) unary() grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		ctx = i.withServiceCtx(ctx)
		ac, err := i.authenticate(ctx)
		if err != nil {
			return nil, err
		}
		authedCtx, err := i.authorize(ctx, ac, info.FullMethod, req)
		if err != nil {
			return nil, err
		}
		return handler(authedCtx, req)
	}
}

// stream is the stream server interceptor. Authentication happens once at
// stream start; the repo-path authorization is re-checked for every message the
// client sends (the streaming reads carry a repo path per message), matching the
// plan's "override Context() and check each RecvMsg" design.
func (i *interceptor) stream() grpc.StreamServerInterceptor {
	return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
		ctx := i.withServiceCtx(ss.Context())
		ac, err := i.authenticate(ctx)
		if err != nil {
			return err
		}
		// A stream to an unknown method is denied before the handler runs.
		if _, _, ok := classify(info.FullMethod); !ok {
			return status.Errorf(codes.PermissionDenied, "unknown rpc method: %s", info.FullMethod)
		}
		wrapped := &authStream{
			ServerStream: ss,
			ctx:          authn.WithCaller(ctx, ac),
			i:            i,
			ac:           ac,
			fullMethod:   info.FullMethod,
		}
		return handler(srv, wrapped)
	}
}

// authStream wraps a grpc.ServerStream so the handler sees the caller-carrying
// context and every received message is re-authorized against its repo path.
type authStream struct {
	grpc.ServerStream
	ctx        context.Context
	i          *interceptor
	ac         *auth.AuthContext
	fullMethod string
}

// Context returns the authenticated, caller-carrying context.
func (s *authStream) Context() context.Context { return s.ctx }

// RecvMsg receives the next message and re-authorizes it against its repo path
// before handing it to the handler. An authorization failure aborts the stream.
func (s *authStream) RecvMsg(m any) error {
	if err := s.ServerStream.RecvMsg(m); err != nil {
		return err
	}
	if _, err := s.i.authorize(s.ctx, s.ac, s.fullMethod, m); err != nil {
		return err
	}
	return nil
}

A remoteapi/interceptors_test.go => remoteapi/interceptors_test.go +310 -0
@@ 0,0 1,310 @@
package remoteapi

import (
	"context"
	"testing"

	"git.sr.ht/~sircmpwn/core-go/auth"
	"git.sr.ht/~sircmpwn/core-go/config"
	remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	"go.bigb.es/sourcehut-dolt/authn"
	"go.bigb.es/sourcehut-dolt/core"
	"go.bigb.es/sourcehut-dolt/db"
)

const (
	mRoot     = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Root"
	mGetMeta  = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetRepoMetadata"
	mCommit   = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Commit"
	mUpload   = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetUploadLocations"
	mDownload = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetDownloadLocations"
	mUnknown  = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Frobnicate"
)

// fakeReq implements repoRequest with a fixed path/id so tests need not depend
// on which concrete proto message carries the repo for a given method.
type fakeReq struct {
	path string
	id   *remotesapi.RepoId
}

func (f fakeReq) GetRepoPath() string           { return f.path }
func (f fakeReq) GetRepoId() *remotesapi.RepoId { return f.id }

// stubStore is an in-memory repoStore: no Postgres.
type stubStore struct {
	repo    *core.Repo
	repoErr error
	acl     *core.AccessMode
	aclErr  error
}

func (s stubStore) GetRepoByOwnerAndName(_ context.Context, _, _ string) (*core.Repo, error) {
	if s.repoErr != nil {
		return nil, s.repoErr
	}
	return s.repo, nil
}

func (s stubStore) EffectiveAccess(_ context.Context, _, _ int) (*core.AccessMode, error) {
	return s.acl, s.aclErr
}

func testInterceptor(store repoStore) *interceptor {
	return &interceptor{
		conf:        ini.File{},
		service:     "dolt.sr.ht",
		expectedAud: "dolt.srht.bigb.es",
		logger:      logrus.NewEntry(logrus.New()),
		stores:      func() repoStore { return store },
	}
}

func repo(id, ownerID int, vis core.Visibility) *core.Repo {
	return &core.Repo{ID: id, Name: "db", OwnerID: ownerID, OwnerName: "alice", Visibility: vis}
}

func acl(m core.AccessMode) *core.AccessMode { return &m }

// patCaller builds an authenticated PAT caller with the given grant string
// (empty ⇒ universal token). config context is needed by DecodeGrants.
func patCaller(t *testing.T, userID int, grants string) *auth.AuthContext {
	t.Helper()
	ctx := config.Context(context.Background(), ini.File{}, "dolt.sr.ht")
	g, err := auth.DecodeGrants(ctx, grants)
	if err != nil {
		t.Fatalf("DecodeGrants(%q): %v", grants, err)
	}
	return &auth.AuthContext{
		UserID:      userID,
		Username:    "alice",
		UserType:    auth.USER_TYPE_USER,
		AuthMethod:  auth.AUTH_OAUTH2,
		BearerToken: &auth.BearerToken{},
		Grants:      g,
	}
}

// cookieCaller builds an authenticated non-token caller (cookie/keypair): no
// BearerToken, so the grant gate passes trivially.
func cookieCaller(userID int) *auth.AuthContext {
	return &auth.AuthContext{
		UserID:     userID,
		Username:   "alice",
		UserType:   auth.USER_TYPE_USER,
		AuthMethod: auth.AUTH_COOKIE,
	}
}

func wantCode(t *testing.T, err error, code codes.Code) {
	t.Helper()
	if status.Code(err) != code {
		t.Fatalf("want gRPC code %s, got %s (err=%v)", code, status.Code(err), err)
	}
}

func TestClassify(t *testing.T) {
	cases := []struct {
		method string
		op     core.Op
		mode   core.AccessMode
		ok     bool
	}{
		{mCommit, core.OpPush, core.AccessRW, true},
		{mUpload, core.OpPush, core.AccessRW, true},
		{mRoot, core.OpCloneRead, core.AccessRO, true},
		{mGetMeta, core.OpCloneRead, core.AccessRO, true},
		{mDownload, core.OpCloneRead, core.AccessRO, true},
		{mUnknown, 0, "", false},
	}
	for _, c := range cases {
		op, mode, ok := classify(c.method)
		if ok != c.ok || (ok && (op != c.op || mode != c.mode)) {
			t.Errorf("classify(%s) = (%v,%v,%v), want (%v,%v,%v)",
				c.method, op, mode, ok, c.op, c.mode, c.ok)
		}
	}
}

func TestAuthorize(t *testing.T) {
	ctx := context.Background()
	req := fakeReq{path: "~alice/db"}

	t.Run("anonymous public read allowed", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
		if _, err := i.authorize(ctx, nil, mGetMeta, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("anonymous public push denied (visible)", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
		_, err := i.authorize(ctx, nil, mCommit, req)
		wantCode(t, err, codes.PermissionDenied)
	})

	t.Run("anonymous private read is not found", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPrivate)})
		_, err := i.authorize(ctx, nil, mGetMeta, req)
		wantCode(t, err, codes.NotFound)
	})

	t.Run("owner push allowed", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
		if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("non-owner private read no acl is not found", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
		_, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req)
		wantCode(t, err, codes.NotFound)
	})

	t.Run("acl RO reads private", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
		if _, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("acl RO cannot push private (visible ⇒ PermissionDenied)", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
		_, err := i.authorize(ctx, cookieCaller(7), mCommit, req)
		wantCode(t, err, codes.PermissionDenied)
	})

	t.Run("acl RW pushes private", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRW)})
		if _, err := i.authorize(ctx, cookieCaller(7), mCommit, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("unknown method denied", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
		_, err := i.authorize(ctx, nil, mUnknown, req)
		wantCode(t, err, codes.PermissionDenied)
	})

	t.Run("malformed path is invalid argument", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
		_, err := i.authorize(ctx, nil, mGetMeta, fakeReq{path: "no-slash"})
		wantCode(t, err, codes.InvalidArgument)
	})

	t.Run("missing repo row is not found", func(t *testing.T) {
		i := testInterceptor(stubStore{repoErr: db.ErrNotFound})
		_, err := i.authorize(ctx, nil, mGetMeta, req)
		wantCode(t, err, codes.NotFound)
	})

	t.Run("root ping with no path is allowed", func(t *testing.T) {
		i := testInterceptor(stubStore{})
		got, err := i.authorize(ctx, nil, mRoot, fakeReq{})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if got == nil {
			t.Fatal("expected a caller context")
		}
	})

	t.Run("non-root with no path is invalid argument", func(t *testing.T) {
		i := testInterceptor(stubStore{})
		_, err := i.authorize(ctx, nil, mGetMeta, fakeReq{})
		wantCode(t, err, codes.InvalidArgument)
	})

	t.Run("repo id shape resolves path", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
		r := fakeReq{id: &remotesapi.RepoId{Org: "alice", RepoName: "db"}}
		if _, err := i.authorize(ctx, nil, mGetMeta, r); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})
}

func TestAuthorizeGrantGate(t *testing.T) {
	ctx := context.Background()
	req := fakeReq{path: "~alice/db"}

	t.Run("PAT without RO grant denied on read", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		_, err := i.authorize(ctx, patCaller(t, 42, "meta.sr.ht/profile:RO"), mGetMeta, req)
		wantCode(t, err, codes.PermissionDenied)
	})

	t.Run("PAT with RO grant allowed on read of own repo", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mGetMeta, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("PAT with RO grant cannot push", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		_, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mCommit, req)
		wantCode(t, err, codes.PermissionDenied)
	})

	t.Run("PAT with RW grant pushes own repo", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RW"), mCommit, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("universal PAT (empty grants) passes gate", func(t *testing.T) {
		i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		if _, err := i.authorize(ctx, patCaller(t, 42, ""), mCommit, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})
}

// TestUnaryAnonymous exercises the full unary path (authenticate + authorize)
// for an anonymous request: no metadata ⇒ anonymous caller, no crypto/PG.
func TestUnaryAnonymous(t *testing.T) {
	i := &interceptor{
		conf:    ini.File{},
		service: "dolt.sr.ht",
		logger:  logrus.NewEntry(logrus.New()),
		stores:  func() repoStore { return stubStore{repo: repo(1, 100, core.VisibilityPublic)} },
	}
	var gotCaller bool
	handler := func(ctx context.Context, _ any) (any, error) {
		// The handler must see a caller-carrying context (anonymous ⇒ nil ac).
		gotCaller = authn.CallerFromContext(ctx) == nil
		return "ok", nil
	}
	info := &grpc.UnaryServerInfo{FullMethod: mGetMeta}
	resp, err := i.unary()(context.Background(), fakeReq{path: "~alice/db"}, info, handler)
	if err != nil {
		t.Fatalf("unary: %v", err)
	}
	if resp != "ok" || !gotCaller {
		t.Fatalf("handler not invoked with anonymous caller context (resp=%v)", resp)
	}
}

func TestNormalizeAud(t *testing.T) {
	cases := map[string]string{
		"dolt.srht.bigb.es":     "dolt.srht.bigb.es",
		"dolt.srht.bigb.es:443": "dolt.srht.bigb.es",
		"127.0.0.1:5306":        "127.0.0.1",
		"":                      "",
	}
	for in, want := range cases {
		if got := normalizeAud(in); got != want {
			t.Errorf("normalizeAud(%q) = %q, want %q", in, got, want)
		}
	}
}

A remoteapi/keystore.go => remoteapi/keystore.go +60 -0
@@ 0,0 1,60 @@
// Package remoteapi assembles dolt.sr.ht's remotesapi subsystem: the gRPC
// authentication/authorization interceptors, the importable remotesrv server
// that serves the bare NBS chunk stores, and the small separate
// CredentialsService (WhoAmI) server that backs `dolt login`.
//
// The two gRPC surfaces run on two ports (nginx path-routes between them):
//
//   - the remotesapi ChunkStoreService (clone/push data plane) on ListenAddr,
//     wrapped by our per-repo authz interceptors (see interceptors.go);
//   - the CredentialsService.WhoAmI RPC on CredsListenAddr (see credsvc.go),
//     which lets the dolt CLI discover which SourceHut user a keypair maps to
//     while it polls for the key to be associated through the web UI.
package remoteapi

import (
	"context"
	"database/sql"

	"go.bigb.es/sourcehut-dolt/authn"
	"go.bigb.es/sourcehut-dolt/db"
)

// keyStore adapts the SQL persistence layer (db.Store over the shared *sql.DB
// pool) to the authn.KeyStore interface the Bearer-JWT resolver consumes. It
// wraps the pool rather than a context-bound Store so it can be constructed once
// at startup and shared by both the interceptor path and the credentials
// service; each call binds a fresh db.Store to the pool (which manages
// connection lifetime) and threads ctx for cancellation.
type keyStore struct {
	db *sql.DB
}

var _ authn.KeyStore = (*keyStore)(nil)

// newKeyStore builds a keyStore over the shared database pool. pool must be
// non-nil.
func newKeyStore(pool *sql.DB) *keyStore {
	if pool == nil {
		panic("remoteapi: newKeyStore requires a non-nil *sql.DB")
	}
	return &keyStore{db: pool}
}

// ByKID resolves a dolt key id to the owner's raw Ed25519 public key and
// username, mapping the db layer's KeyAuth onto authn.KeyStore's contract. A
// missing key surfaces as db.ErrNotFound (returned unchanged), which the JWT
// resolver wraps into an invalid-token rejection.
func (k *keyStore) ByKID(ctx context.Context, kid string) (pubkey []byte, username string, err error) {
	ka, err := db.NewStore(k.db).KeyByKID(ctx, kid)
	if err != nil {
		return nil, "", err
	}
	return ka.PubKey, ka.Username, nil
}

// TouchLastUsed stamps the key's last_used column after a successful keypair
// authentication.
func (k *keyStore) TouchLastUsed(ctx context.Context, kid string) error {
	return db.NewStore(k.db).TouchKeyLastUsed(ctx, kid)
}

A remoteapi/server.go => remoteapi/server.go +165 -0
@@ 0,0 1,165 @@
package remoteapi

import (
	"context"
	"database/sql"
	"fmt"
	"net"

	remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
	"github.com/dolthub/dolt/go/libraries/doltcore/remotesrv"
	"github.com/dolthub/dolt/go/libraries/utils/filesys"
	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"

	"go.bigb.es/sourcehut-dolt/db"
	"go.bigb.es/sourcehut-dolt/storage"
)

// serviceName is the SourceHut service identifier for dolt.sr.ht. It selects
// the config section and OAuth grant namespace used across the auth stack.
const serviceName = "dolt.sr.ht"

// Config configures the remotesapi server assembly. It is the single struct the
// main wiring populates for both New (the chunk-store server) and NewCredServer
// (the credentials server); each uses the subset it needs.
type Config struct {
	// Conf is the loaded instance config (the shared config.ini as ini.File),
	// threaded into request contexts so the auth resolvers can read
	// [webhooks]private-key, network-key, meta origin, etc.
	Conf ini.File
	// DB is the shared Postgres pool.
	DB *sql.DB
	// ReposRoot is the absolute directory under which bare NBS stores live
	// ("<ReposRoot>/~<owner>/<name>"). The remotesrv filesys is rooted here.
	ReposRoot string
	// ListenAddr is the host:port for the remotesapi (gRPC + HTTP chunk data
	// plane multiplexed on one h2c port).
	ListenAddr string
	// CredsListenAddr is the host:port for the separate CredentialsService
	// (WhoAmI) gRPC server.
	CredsListenAddr string
	// HttpHost is the authority the server stamps into sealed chunk-download
	// URLs (e.g. "dolt.srht.bigb.es"; may carry a port for local testing, e.g.
	// "127.0.0.1:5306"). It also seeds the expected JWT audience: dolt's client
	// derives the audience with net.SplitHostPort(endpoint), i.e. the bare host
	// with any port stripped, so the audience the server must expect is
	// normalizeAud(HttpHost). Leave empty to echo the request :authority into
	// chunk URLs (auth then cannot be host-checked; used only by tests without a
	// stable host).
	HttpHost string
	// Logger is the base logger; may be nil (a default is used).
	Logger *logrus.Entry
}

// Server is the assembled remotesapi server: the remotesrv chunk-store server
// plus the storage cache it serves from. The cache is exposed so the web delete
// flow can evict a store when its repository is removed.
type Server struct {
	srv    *remotesrv.Server
	cache  *storage.Cache
	logger *logrus.Entry
	addr   string
}

// New assembles the remotesapi chunk-store server: a db-backed repo lookup, the
// storage cache, our auth/authz interceptors, and the importable remotesrv
// server bound to a single h2c port. It does not start listening; call Serve.
func New(cfg Config) (*Server, error) {
	if cfg.DB == nil {
		return nil, fmt.Errorf("remoteapi: New requires a non-nil DB")
	}
	if cfg.ReposRoot == "" {
		return nil, fmt.Errorf("remoteapi: New requires a ReposRoot")
	}
	logger := cfg.Logger
	if logger == nil {
		logger = logrus.NewEntry(logrus.StandardLogger())
	}

	// Repo lookup: resolve owner/name to the absolute on-disk store dir via the
	// repository row. v1 has no push-to-create, so a missing row is an error the
	// cache propagates (mapped to gRPC NotFound at the interceptor layer, which
	// runs first anyway).
	lookup := func(ctx context.Context, owner, name string) (string, error) {
		repo, err := db.NewStore(cfg.DB).GetRepoByOwnerAndName(ctx, owner, name)
		if err != nil {
			return "", err
		}
		return repo.Path, nil
	}
	cache := storage.NewCache(lookup)

	keys := newKeyStore(cfg.DB)
	icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, logger)

	// Load-bearing (see storage/init.go): the FS MUST be rooted at ReposRoot via
	// LocalFilesysWithWorkingDir so sealed chunk-download URLs carry clean
	// relative prefixes; a bare LocalFS breaks every clone/push at chunk
	// transfer.
	fs, err := filesys.LocalFilesysWithWorkingDir(cfg.ReposRoot)
	if err != nil {
		return nil, fmt.Errorf("remoteapi: root filesys at %q: %w", cfg.ReposRoot, err)
	}

	srv, err := remotesrv.NewServer(remotesrv.ServerArgs{
		Logger:             logger,
		HttpHost:           cfg.HttpHost,
		HttpListenAddr:     cfg.ListenAddr,
		GrpcListenAddr:     cfg.ListenAddr, // == HttpListenAddr ⇒ single h2c port
		FS:                 fs,
		DBCache:            cache,
		ReadOnly:           false,
		Options:            icept.Options(),
		ConcurrencyControl: remotesapi.PushConcurrencyControl_PUSH_CONCURRENCY_CONTROL_IGNORE_WORKING_SET,
	})
	if err != nil {
		if cerr := cache.Close(); cerr != nil {
			logger.Warnf("remoteapi: closing cache after NewServer failure: %v", cerr)
		}
		return nil, fmt.Errorf("remoteapi: remotesrv.NewServer: %w", err)
	}

	return &Server{srv: srv, cache: cache, logger: logger, addr: cfg.ListenAddr}, nil
}

// Cache returns the storage cache backing this server so the web delete flow
// can evict a store on repository removal.
func (s *Server) Cache() *storage.Cache { return s.cache }

// Serve binds the listeners and serves until GracefulStop. It blocks. It
// returns an error only if binding the listeners fails; the underlying
// remotesrv.Serve blocks until shutdown and does not return an error.
func (s *Server) Serve() error {
	listeners, err := s.srv.Listeners()
	if err != nil {
		return fmt.Errorf("remoteapi: bind %q: %w", s.addr, err)
	}
	s.srv.Serve(listeners)
	return nil
}

// GracefulStop stops the server and closes every memoized chunk store.
func (s *Server) GracefulStop() {
	s.srv.GracefulStop()
	if err := s.cache.Close(); err != nil {
		s.logger.Warnf("remoteapi: closing cache on shutdown: %v", err)
	}
}

// normalizeAud reduces a configured HttpHost to the bare host the dolt client
// puts in a JWT audience. Verified against dolt's grpc_dial_provider:
// getHostFromEndpoint(endpoint) calls net.SplitHostPort and returns the host
// with any port stripped, so the audience the server receives is always the
// bare host. Normalizing here lets operators write either "dolt.srht.bigb.es"
// or "dolt.srht.bigb.es:443" (or a "127.0.0.1:PORT" test host) and get the same
// expected audience. An empty host yields an empty audience (no keypair auth).
func normalizeAud(host string) string {
	if host == "" {
		return ""
	}
	if h, _, err := net.SplitHostPort(host); err == nil {
		return h
	}
	return host
}