From b8da4e2318033a7218770d40b69cdf89d33e3d80 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 13:22:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20ownerOnly/adminOnly=20=E5=8C=85?= =?UTF-8?q?=E5=9C=A8=E5=B7=B2=E6=9C=89=E7=9A=84=E5=8C=85=E8=A3=85=E5=A4=96?= =?UTF-8?q?=E5=B1=82=EF=BC=8C=E8=B7=AF=E7=94=B1=E7=9A=84=E6=AF=8F=E5=B1=82?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E9=83=BD=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/api.go | 4 ++-- internal/api/route_tiers_test.go | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 internal/api/route_tiers_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 5bcfcda..6f6ca0c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -752,10 +752,10 @@ func (a *API) buildFace(face string, routes []apiRoute, guard func(http.Handler) h = callersOnly(rt.Callers, h) } if rt.Owner { - h = a.ownerOnly(rt.h) + h = a.ownerOnly(h) } if rt.Admin { - h = a.adminOnly(rt.h) + h = a.adminOnly(h) } // Default-deny setup-lockdown: wrap every authenticated route unless it // explicitly opts out. The wrapper is nil-principal safe, so it is inert on diff --git a/internal/api/route_tiers_test.go b/internal/api/route_tiers_test.go new file mode 100644 index 0000000..7110c42 --- /dev/null +++ b/internal/api/route_tiers_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// Each tier a route sets is one more gate in front of its handler: a route marked +// both Owner and Admin still refuses an admin who is not the owner. +func TestBuildFaceStacksTierGates(t *testing.T) { + a := newTestAPI(newFakeRepo(), newFakeCluster()) + ran := false + ok := func(w http.ResponseWriter, r *http.Request) { ran = true } + as := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := &Principal{UserID: "u1", Role: r.Header.Get("X-Test-Role"), ViaAdminAccess: true, EmailVerified: true} + h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyPrincipal, p))) + }) + } + h := a.buildFace("external", []apiRoute{{Method: "GET", Pattern: "/api/v1/tiered", Owner: true, Admin: true, h: ok}}, as) + + for _, c := range []struct { + role string + code int + }{ + {"admin", http.StatusForbidden}, + {"user", http.StatusForbidden}, + {"owner", http.StatusOK}, + } { + ran = false + r := httptest.NewRequest("GET", "/api/v1/tiered", nil) + r.Header.Set("X-Test-Role", c.role) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != c.code || ran != (c.code == http.StatusOK) { + t.Errorf("%s: status %d, handler ran %v; want %d", c.role, w.Code, ran, c.code) + } + } +}