gitmote

gitmote / internal / auth / guard.go  ·  history  ·  branches

6546 bytes · raw

package auth

import (
	"context"
	"crypto/subtle"
	"errors"
	"net/http"
	"strings"
	"time"

	"github.com/atmin/gitmote/internal/meta"
)

// Guard authenticates PATs and authorizes requests per-repo against the ACL
// table. It is the request guard the transport layer consults before serving.
type Guard struct {
	md  *meta.Metadata
	now func() time.Time // injectable clock for token expiry (time.Now in prod)
}

// NewGuard returns a Guard backed by the metadata layer.
func NewGuard(md *meta.Metadata) *Guard { return &Guard{md: md, now: time.Now} }

// Authorize checks the request may act on repoName at perm. A public repo is
// readable with no token; every other case needs a valid token plus the ACL
// level. The returned error selects the response:
//
//   - ErrUnauthorized — no/invalid token where one is required: 401 with a Basic challenge;
//   - ErrForbidden — authenticated but lacking the permission: 403;
//   - meta.ErrNotFound — the repo does not exist: 404;
//   - any other error — an internal failure: 500.
//
// On success it returns the authenticated user, or nil for an anonymous read of
// a public repo (reads never dereference the user; only the write path does).
func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error) {
	ctx := r.Context()

	raw, hasToken := tokenFromRequest(r)

	repo, err := g.md.GetRepo(ctx, repoName)
	if errors.Is(err, meta.ErrNotFound) {
		// Hide a private forge's repo inventory from anonymous callers: an
		// unauthenticated request never learns whether a repo exists (it gets the
		// same 401 challenge whether or not it does).
		if !hasToken {
			return nil, ErrUnauthorized
		}
		return nil, err // 404 to an authenticated caller
	}
	if err != nil {
		return nil, err
	}

	// Anonymous: allowed only for reading a public repo; anything else needs a token.
	if !hasToken {
		if perm == meta.PermRead && repo.Public() {
			return nil, nil
		}
		return nil, ErrUnauthorized
	}

	vt, err := g.verify(ctx, raw)
	if err != nil {
		return nil, err
	}

	// Token constraints gate before the ACL: a repo-scoped token reaches only its
	// one repo, and a read-only token cannot perform a write/admin operation —
	// even where the owner's ACL would otherwise allow it.
	if vt.repoScope != nil && *vt.repoScope != repo.ID {
		return nil, ErrForbidden
	}
	if vt.readOnly && permRank[perm] > permRank[meta.PermRead] {
		return nil, ErrForbidden
	}

	// Reading is gated by repo-read (public → anyone, private → any ACL), not by a
	// specific ACL level; write/admin require the matching ACL level.
	if perm == meta.PermRead {
		ok, err := g.md.CanRead(ctx, repo, vt.user.ID)
		if err != nil {
			return nil, err
		}
		if !ok {
			return nil, ErrForbidden
		}
		return &vt.user, nil
	}

	granted, err := g.md.GetACL(ctx, repo.ID, vt.user.ID)
	if errors.Is(err, meta.ErrNotFound) {
		return nil, ErrForbidden // no ACL row means no access
	}
	if err != nil {
		return nil, err
	}
	if !allows(granted, perm) {
		return nil, ErrForbidden
	}
	return &vt.user, nil
}

// verifiedToken is a successfully authenticated token: its owner plus the
// constraints Authorize enforces.
type verifiedToken struct {
	user      meta.User
	repoScope *int64
	readOnly  bool
}

// VerifyToken resolves a raw PAT string to its owner, or ErrUnauthorized. It is
// the shared verification path: the request guard calls it after extracting the
// token from the Authorization header, and the web UI's login form calls it with
// a pasted token. All failures collapse to ErrUnauthorized.
func (g *Guard) VerifyToken(ctx context.Context, raw string) (*meta.User, error) {
	vt, err := g.verify(ctx, raw)
	if err != nil {
		return nil, err
	}
	return &vt.user, nil
}

// verify authenticates a raw token and returns its owner and constraints. It
// checks the verifier in constant time and rejects an expired token. All
// failures collapse to ErrUnauthorized so a client cannot distinguish "unknown
// token" from "wrong secret" from "expired".
func (g *Guard) verify(ctx context.Context, raw string) (*verifiedToken, error) {
	selector, secret, ok := split(raw)
	if !ok {
		return nil, ErrUnauthorized
	}
	ta, err := g.md.TokenBySelector(ctx, selector)
	if errors.Is(err, meta.ErrNotFound) {
		return nil, ErrUnauthorized
	}
	if err != nil {
		return nil, err
	}
	if subtle.ConstantTimeCompare([]byte(ta.Verifier), []byte(hashSecret(secret))) != 1 {
		return nil, ErrUnauthorized
	}
	if ta.ExpiresAt != nil && !ta.ExpiresAt.After(g.now()) {
		return nil, ErrUnauthorized
	}
	if err := g.md.TouchToken(ctx, ta.TokenID); err != nil {
		return nil, err
	}
	return &verifiedToken{user: ta.User, repoScope: ta.RepoScope, readOnly: ta.ReadOnly}, nil
}

// MintScoped creates and persists a token with optional constraints — repoScope
// (nil = all the owner's repos), readOnly (deny push), expiresAt (zero = never) —
// and returns the raw token string, shown exactly once. It is the CI clone
// credential mint (task 21) and any expiring/scoped PAT.
func (g *Guard) MintScoped(ctx context.Context, userID int64, label string, repoScope *int64, readOnly bool, expiresAt time.Time) (string, error) {
	raw, selector, verifier, err := Mint()
	if err != nil {
		return "", err
	}
	if _, err := g.md.CreateScopedToken(ctx, userID, selector, verifier, label, repoScope, readOnly, expiresAt); err != nil {
		return "", err
	}
	return raw, nil
}

// tokenFromRequest extracts a PAT from the Authorization header. It accepts a
// Bearer token (API clients) and Basic credentials (git's native HTTP auth,
// where the PAT is sent as the password — or the username when the password is
// empty).
func tokenFromRequest(r *http.Request) (string, bool) {
	h := r.Header.Get("Authorization")
	switch {
	case h == "":
		return "", false
	case strings.HasPrefix(h, "Bearer "):
		if tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")); tok != "" {
			return tok, true
		}
		return "", false
	default:
		user, pass, ok := r.BasicAuth()
		if !ok {
			return "", false
		}
		if pass != "" {
			return pass, true
		}
		if user != "" {
			return user, true
		}
		return "", false
	}
}

// permRank orders permissions read < write < admin; a missing/unknown level is
// rank 0 (no access).
var permRank = map[meta.Perm]int{
	meta.PermRead:  1,
	meta.PermWrite: 2,
	meta.PermAdmin: 3,
}

// allows reports whether the granted permission covers the required one.
func allows(granted, required meta.Perm) bool {
	req := permRank[required]
	return req > 0 && permRank[granted] >= req
}