package remoteapi
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
"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/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/database"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
"sourcecraft.dev/bigbes/sr-ht-dolt/storage"
)
// 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)
// CreateRepo and DeleteRepo back push-to-create: an authenticated caller
// touching a not-yet-existing repo in their own namespace has the row
// inserted (CreateRepo) and, if the on-disk store then fails to materialize,
// rolled back (DeleteRepo).
CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error)
DeleteRepo(ctx context.Context, id int) 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 *slog.Logger
// 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
// reposRoot is the absolute directory under which bare NBS stores live; it
// resolves the on-disk path for a push-to-created repository.
reposRoot string
// createStore materializes the empty on-disk store for a push-to-created
// repository. Production uses storage.InitEmptyStore; tests inject a fake.
createStore func(ctx context.Context, absPath string) error
}
// 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.
//
// The logger is not a parameter: it is slog's default with this component's
// name on it. Threading one through the constructor was logrus' requirement,
// not this package's — there is no configuration here a caller ever varied.
func newInterceptor(conf ini.File, service, expectedAud string, pool *sql.DB, keys authn.KeyStore, reposRoot string, createStore func(ctx context.Context, absPath string) error) *interceptor {
if pool == nil {
panic("remoteapi: newInterceptor requires a non-nil *sql.DB")
}
if createStore == nil {
createStore = storage.InitEmptyStore
}
i := &interceptor{
conf: conf,
service: service,
expectedAud: expectedAud,
keys: keys,
logger: slog.Default().With("component", "remotesapi.auth"),
pool: pool,
reposRoot: reposRoot,
createStore: createStore,
}
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.WarnContext(ctx, "authentication rejected", scribe.Err(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.ErrorContext(ctx, "authentication backend error", scribe.Err(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)
}
caller := authn.AsCoreCaller(ac)
store := i.stores()
repo, err := store.GetRepoByOwnerAndName(ctx, owner, name)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
// Push-to-create: an authenticated, non-suspended caller touching a
// not-yet-existing repo in THEIR OWN namespace (with a valid name)
// has it transparently created — a PRIVATE row plus a genuinely
// empty on-disk store — then proceeds through the normal ACL check
// as the owner. Any other case (anonymous, suspended, another
// user's namespace, invalid name) keeps returning NotFound, which
// also avoids leaking existence of a stranger's private repos.
if caller == nil || caller.Suspended || owner != caller.Username || core.ValidateName(name) != nil {
return nil, status.Errorf(codes.NotFound, "repository %s/%s not found", owner, name)
}
repo, err = i.autoCreate(ctx, store, caller, owner, name)
if err != nil {
return nil, err
}
} else {
i.logger.ErrorContext(ctx, "repository lookup failed",
"owner", owner, "name", name, scribe.Err(err))
return nil, status.Error(codes.Unavailable, "repository lookup temporarily unavailable")
}
}
var aclMode *core.AccessMode
if caller != nil {
aclMode, err = store.EffectiveAccess(ctx, caller.UserID, repo.ID)
if err != nil {
i.logger.ErrorContext(ctx, "effective-access lookup failed",
"user_id", caller.UserID, "repo_id", repo.ID, scribe.Err(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
}
// autoCreate transparently creates a PRIVATE repository owned by caller under
// owner/name (push-to-create). It inserts the row and materializes a genuinely
// empty on-disk store. A concurrent first-push that already created the row is
// tolerated: the caller loses the CreateRepo race, re-fetches the winner's row,
// and does NOT re-create the store. If the store fails to materialize, the row
// is rolled back so a later push retries cleanly. Preconditions (authenticated,
// non-suspended, owns the namespace, valid name) are checked by the caller.
func (i *interceptor) autoCreate(ctx context.Context, store repoStore, caller *core.Caller, owner, name string) (*core.Repo, error) {
newRepo := &core.Repo{
Name: name,
OwnerID: caller.UserID,
OwnerName: owner,
Path: storage.RepoDiskPath(i.reposRoot, owner, name),
Visibility: core.VisibilityPrivate,
}
created, cerr := store.CreateRepo(ctx, newRepo)
if cerr != nil {
if errors.Is(cerr, db.ErrNameTaken) {
// Lost the race: another first-push created the row (and its store).
// Adopt the winner's row; do not touch disk.
repo, gerr := store.GetRepoByOwnerAndName(ctx, owner, name)
if gerr != nil {
i.logger.ErrorContext(ctx, "auto-create refetch failed",
"owner", owner, "name", name, scribe.Err(gerr))
return nil, status.Error(codes.Unavailable, "repository creation temporarily unavailable")
}
return repo, nil
}
i.logger.ErrorContext(ctx, "auto-create failed",
"owner", owner, "name", name, scribe.Err(cerr))
return nil, status.Error(codes.Unavailable, "repository creation temporarily unavailable")
}
if serr := i.createStore(ctx, created.Path); serr != nil {
// The row exists but the store does not: roll the row back (best effort)
// so the repo does not linger half-created and a retry can succeed.
if derr := store.DeleteRepo(ctx, created.ID); derr != nil {
i.logger.ErrorContext(ctx, "auto-create rollback failed",
"owner", owner, "name", name, "repo_id", created.ID, scribe.Err(derr))
}
i.logger.ErrorContext(ctx, "auto-create store initialization failed",
"owner", owner, "name", name, scribe.Err(serr))
return nil, status.Error(codes.Unavailable, "repository creation temporarily unavailable")
}
return created, 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
}