add a simple auth function

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-04-25 20:11:46 +02:00
commit 87fe581353
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
10 changed files with 206 additions and 8 deletions

View file

@ -49,7 +49,7 @@ var serveCmd = &cobra.Command{
syncH := api.NewSyncHandler(syncSvc)
snapshotH := api.NewSnapshotHandler(snapshotSvc)
proxyH := api.NewProxyHandler(repoSvc, cfg.DataDir)
router := api.NewRouter(repoH, cloneH, syncH, snapshotH, proxyH)
router := api.NewRouter(repoH, cloneH, syncH, snapshotH, proxyH, cfg.Auth.LocalhostPrivileged)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

View file

@ -11,6 +11,7 @@ type Config struct {
DataDir string `mapstructure:"data_dir"`
Sync SyncConfig `mapstructure:"sync"`
Log LogConfig `mapstructure:"log"`
Auth AuthConfig `mapstructure:"auth"`
}
type ServerConfig struct {
@ -31,6 +32,10 @@ type LogConfig struct {
Format string `mapstructure:"format"`
}
type AuthConfig struct {
LocalhostPrivileged bool `mapstructure:"localhost_privileged"`
}
func (l LogConfig) SlogLevel() slog.Level {
switch l.Level {
case "debug":

View file

@ -28,6 +28,7 @@ func Load(cfgFile string) (*Config, error) {
viper.SetDefault("sync.interval", "1h")
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", "text")
viper.SetDefault("auth.localhost_privileged", true)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {

91
internal/api/auth.go Normal file
View file

@ -0,0 +1,91 @@
package api
import (
"context"
"errors"
"net"
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
// Action identifies the operation being performed.
// Format: "resource:verb"
type Action string
const (
ActionRepoList Action = "repos:list"
ActionRepoCreate Action = "repos:create"
ActionRepoRead Action = "repos:read"
ActionRepoUpdate Action = "repos:update"
ActionRepoDelete Action = "repos:delete"
ActionCloneStart Action = "clone:start"
ActionCloneRead Action = "clone:read"
ActionSyncTrigger Action = "sync:trigger"
ActionSyncRead Action = "sync:read"
ActionSyncApprove Action = "sync:approve"
ActionSyncReject Action = "sync:reject"
ActionSyncBlock Action = "sync:block"
ActionSnapshotCreate Action = "snapshots:create"
ActionSnapshotRead Action = "snapshots:read"
ActionSnapshotDelete Action = "snapshots:delete"
ActionSnapshotRollback Action = "snapshots:rollback"
)
var ErrForbidden = errors.New("forbidden")
// Caller holds the identity injected into the request context by LocalhostAuth.
type Caller struct {
IP string
RequestID string
LocalhostPrivileged bool
}
type contextKey string
const callerKey contextKey = "caller"
// CallerFromContext returns the Caller injected by LocalhostAuth, or nil.
func CallerFromContext(ctx context.Context) *Caller {
c, _ := ctx.Value(callerKey).(*Caller)
return c
}
// Authorize checks whether the caller in ctx is allowed to perform action on repoID.
// repoID == 0 means the action is not scoped to a specific repo (e.g. list, create).
//
// If localhost_privileged is enabled, 127.0.0.1/::1 are always allowed without a token.
// Other callers are currently allowed too; token enforcement will be added with the web UI.
func Authorize(ctx context.Context, action Action, repoID int64) error {
caller := CallerFromContext(ctx)
if caller == nil {
return ErrForbidden
}
if caller.LocalhostPrivileged && (caller.IP == "127.0.0.1" || caller.IP == "::1") {
return nil
}
// Future: look up token permissions for non-localhost callers.
return nil
}
// LocalhostAuth injects a Caller into the context for downstream Authorize calls.
// localhostPrivileged controls whether 127.0.0.1/::1 bypass future token enforcement.
func LocalhostAuth(localhostPrivileged bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
caller := &Caller{
IP: ip,
RequestID: middleware.GetReqID(r.Context()),
LocalhostPrivileged: localhostPrivileged,
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), callerKey, caller)))
})
}
}

View file

@ -25,7 +25,10 @@ func (h *CloneHandler) StartClone(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionCloneStart, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
jobID, err := h.svc.StartClone(r.Context(), repoID)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")
@ -49,7 +52,10 @@ func (h *CloneHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionCloneRead, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
job, err := h.svc.GetLatestCloneJob(r.Context(), repoID)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "no clone job found for this repo")

View file

@ -42,6 +42,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *sql.DB) {
api.NewSyncHandler(syncSvc),
api.NewSnapshotHandler(snapshotSvc),
api.NewProxyHandler(repoSvc, dataDir),
true,
)
srv := httptest.NewServer(router)
@ -77,6 +78,31 @@ func decodeJSON(t *testing.T, resp *http.Response, v any) {
}
}
// --- Auth ---
func TestAuth_allowsNonLocalhost(t *testing.T) {
srv, _ := newTestServer(t)
// Non-localhost callers are currently allowed — localhost is privileged, not exclusive.
router := srv.Config.Handler
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/repos", nil)
req.RemoteAddr = "203.0.113.1:12345"
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 for non-localhost (no token system yet), got %d", w.Code)
}
}
func TestAuth_allowsLocalhost(t *testing.T) {
srv, _ := newTestServer(t)
resp := do(t, http.MethodGet, srv.URL+"/api/v1/repos", nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 from localhost, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// --- Health ---
func TestHealth(t *testing.T) {

View file

@ -21,6 +21,10 @@ func NewRepoHandler(svc *core.RepoService) *RepoHandler {
}
func (h *RepoHandler) Create(w http.ResponseWriter, r *http.Request) {
if err := Authorize(r.Context(), ActionRepoCreate, 0); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req CreateRepoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid JSON")
@ -44,6 +48,10 @@ func (h *RepoHandler) Create(w http.ResponseWriter, r *http.Request) {
}
func (h *RepoHandler) List(w http.ResponseWriter, r *http.Request) {
if err := Authorize(r.Context(), ActionRepoList, 0); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
repos, err := h.svc.List(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, err.Error())
@ -63,7 +71,10 @@ func (h *RepoHandler) Get(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionRepoRead, id); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
repo, err := h.svc.Get(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")
@ -82,7 +93,10 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionRepoUpdate, id); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req UpdateRepoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid JSON")
@ -113,7 +127,10 @@ func (h *RepoHandler) Delete(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionRepoDelete, id); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
if err := h.svc.Delete(r.Context(), id); errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")
return

View file

@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
func NewRouter(repoHandler *RepoHandler, cloneHandler *CloneHandler, syncHandler *SyncHandler, snapshotHandler *SnapshotHandler, proxyHandler *ProxyHandler) http.Handler {
func NewRouter(repoHandler *RepoHandler, cloneHandler *CloneHandler, syncHandler *SyncHandler, snapshotHandler *SnapshotHandler, proxyHandler *ProxyHandler, localhostPrivileged bool) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@ -20,6 +20,7 @@ func NewRouter(repoHandler *RepoHandler, cloneHandler *CloneHandler, syncHandler
})
r.Route("/api/v1", func(r chi.Router) {
r.Use(LocalhostAuth(localhostPrivileged))
r.Route("/repos", func(r chi.Router) {
r.Post("/", repoHandler.Create)
r.Get("/", repoHandler.List)

View file

@ -26,6 +26,10 @@ func (h *SnapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSnapshotCreate, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req CreateSnapshotRequest
json.NewDecoder(r.Body).Decode(&req)
if req.Label == "" {
@ -49,6 +53,10 @@ func (h *SnapshotHandler) List(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSnapshotRead, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
snaps, err := h.svc.List(r.Context(), repoID)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")
@ -75,6 +83,10 @@ func (h *SnapshotHandler) Get(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid snap_id")
return
}
if err := Authorize(r.Context(), ActionSnapshotRead, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
snap, pkgs, err := h.svc.Get(r.Context(), repoID, snapID)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "snapshot not found")
@ -104,6 +116,10 @@ func (h *SnapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid snap_id")
return
}
if err := Authorize(r.Context(), ActionSnapshotDelete, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
if err := h.svc.Delete(r.Context(), repoID, snapID); errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "snapshot not found")
return
@ -120,6 +136,10 @@ func (h *SnapshotHandler) Diff(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSnapshotRead, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
fromID, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid from param")
@ -163,6 +183,10 @@ func (h *SnapshotHandler) Rollback(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid snap_id")
return
}
if err := Authorize(r.Context(), ActionSnapshotRollback, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
if err := h.svc.Rollback(r.Context(), repoID, snapID); errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "snapshot not found")
return

View file

@ -27,7 +27,10 @@ func (h *SyncHandler) Trigger(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
// Validate repo exists before returning.
if err := Authorize(r.Context(), ActionSyncTrigger, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
if err := h.svc.ValidateRepo(r.Context(), repoID); errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")
return
@ -49,6 +52,10 @@ func (h *SyncHandler) ListPending(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSyncRead, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
pkgs, err := h.svc.ListPending(r.Context(), repoID)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")
@ -70,6 +77,10 @@ func (h *SyncHandler) Approve(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSyncApprove, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req SyncSelectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 {
Error(w, http.StatusBadRequest, "body must contain non-empty ids array")
@ -97,6 +108,10 @@ func (h *SyncHandler) Reject(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSyncReject, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req SyncSelectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 {
Error(w, http.StatusBadRequest, "body must contain non-empty ids array")
@ -118,6 +133,10 @@ func (h *SyncHandler) Block(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSyncBlock, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req SyncSelectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 {
Error(w, http.StatusBadRequest, "body must contain non-empty ids array")
@ -139,6 +158,10 @@ func (h *SyncHandler) Unblock(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSyncBlock, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
var req SyncSelectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 {
Error(w, http.StatusBadRequest, "body must contain non-empty ids array")
@ -160,6 +183,10 @@ func (h *SyncHandler) ListBlocked(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := Authorize(r.Context(), ActionSyncRead, repoID); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
pkgs, err := h.svc.ListBlocked(r.Context(), repoID)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusNotFound, "repo not found")