From 87fe581353c53c08a77a424f6bf7bc04bc246ef8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 25 Apr 2026 20:11:46 +0200 Subject: [PATCH] add a simple auth function Signed-off-by: GnomeZworc --- cmd/clonepack/serve.go | 2 +- config/config.go | 5 ++ config/loader.go | 1 + internal/api/auth.go | 91 ++++++++++++++++++++++++++++++++ internal/api/clone_handler.go | 10 +++- internal/api/handler_test.go | 26 +++++++++ internal/api/repo_handler.go | 23 ++++++-- internal/api/router.go | 3 +- internal/api/snapshot_handler.go | 24 +++++++++ internal/api/sync_handler.go | 29 +++++++++- 10 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 internal/api/auth.go diff --git a/cmd/clonepack/serve.go b/cmd/clonepack/serve.go index 4755b30..6d5a0d3 100644 --- a/cmd/clonepack/serve.go +++ b/cmd/clonepack/serve.go @@ -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() diff --git a/config/config.go b/config/config.go index de1dcee..e2c6ac9 100644 --- a/config/config.go +++ b/config/config.go @@ -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": diff --git a/config/loader.go b/config/loader.go index 749822e..47eebf1 100644 --- a/config/loader.go +++ b/config/loader.go @@ -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 { diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..dd5bd26 --- /dev/null +++ b/internal/api/auth.go @@ -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))) + }) + } +} diff --git a/internal/api/clone_handler.go b/internal/api/clone_handler.go index ad16ba9..892a1b5 100644 --- a/internal/api/clone_handler.go +++ b/internal/api/clone_handler.go @@ -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") diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 0cfe671..09de5e7 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -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) { diff --git a/internal/api/repo_handler.go b/internal/api/repo_handler.go index d14f756..ffb3b19 100644 --- a/internal/api/repo_handler.go +++ b/internal/api/repo_handler.go @@ -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 diff --git a/internal/api/router.go b/internal/api/router.go index ee14133..34a0f2c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/snapshot_handler.go b/internal/api/snapshot_handler.go index 831d489..395a3c4 100644 --- a/internal/api/snapshot_handler.go +++ b/internal/api/snapshot_handler.go @@ -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 diff --git a/internal/api/sync_handler.go b/internal/api/sync_handler.go index 987eb4d..defb3cb 100644 --- a/internal/api/sync_handler.go +++ b/internal/api/sync_handler.go @@ -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")