package remoteapi
import (
"context"
"database/sql"
"errors"
"fmt"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-core/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"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-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
}