add a simple auth function
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
e1aff8d16b
commit
87fe581353
10 changed files with 206 additions and 8 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
91
internal/api/auth.go
Normal 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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue