Skip to content

Commit f877c7a

Browse files
jeremyederclaude
andcommitted
fix(backend): return 401 for expired tokens in ValidateProjectContext
When a ServiceAccount token expires, the K8s SSAR call returns Unauthorized. Previously this was swallowed into a generic 500. Now returns 401 so the runner can detect expiration and refresh its token. Adds unit tests for ValidateProjectContext covering all response paths (401, 403, 500, 200, 400). Fixes #445 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d97b9a9 commit f877c7a

File tree

2 files changed

+189
-1
lines changed

2 files changed

+189
-1
lines changed

components/backend/handlers/middleware.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,11 @@ func ValidateProjectContext() gin.HandlerFunc {
369369
res, err := reqK8s.AuthorizationV1().SelfSubjectAccessReviews().Create(c.Request.Context(), ssar, v1.CreateOptions{})
370370
if err != nil {
371371
log.Printf("validateProjectContext: SSAR failed for %s: %v", projectHeader, err)
372-
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to perform access review"})
372+
if errors.IsUnauthorized(err) {
373+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token expired or invalid"})
374+
} else {
375+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to perform access review"})
376+
}
373377
c.Abort()
374378
return
375379
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package middleware_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"ambient-code-backend/handlers"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"k8s.io/client-go/rest"
15+
)
16+
17+
func init() {
18+
gin.SetMode(gin.TestMode)
19+
}
20+
21+
// fakeK8sAPI returns an httptest.Server that responds to SelfSubjectAccessReview
22+
// requests with the given status code and body.
23+
func fakeK8sAPI(statusCode int, body string) *httptest.Server {
24+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
w.Header().Set("Content-Type", "application/json")
26+
w.WriteHeader(statusCode)
27+
w.Write([]byte(body))
28+
}))
29+
}
30+
31+
// ssarAllowedBody returns a JSON body for an SSAR response that allows access.
32+
func ssarAllowedBody() string {
33+
return `{
34+
"apiVersion": "authorization.k8s.io/v1",
35+
"kind": "SelfSubjectAccessReview",
36+
"status": {"allowed": true}
37+
}`
38+
}
39+
40+
// ssarDeniedBody returns a JSON body for an SSAR response that denies access.
41+
func ssarDeniedBody() string {
42+
return `{
43+
"apiVersion": "authorization.k8s.io/v1",
44+
"kind": "SelfSubjectAccessReview",
45+
"status": {"allowed": false}
46+
}`
47+
}
48+
49+
// setupRouter creates a gin router with ValidateProjectContext middleware
50+
// and a simple OK handler behind it.
51+
func setupRouter() *gin.Engine {
52+
r := gin.New()
53+
projectGroup := r.Group("/api/projects/:projectName")
54+
projectGroup.Use(handlers.ValidateProjectContext())
55+
projectGroup.GET("/sessions", func(c *gin.Context) {
56+
c.JSON(http.StatusOK, gin.H{"project": c.GetString("project")})
57+
})
58+
return r
59+
}
60+
61+
// doRequest performs a GET request against the test router with the given
62+
// Authorization header and project name.
63+
func doRequest(router *gin.Engine, project, authHeader string) *httptest.ResponseRecorder {
64+
w := httptest.NewRecorder()
65+
req, _ := http.NewRequest("GET", "/api/projects/"+project+"/sessions", nil)
66+
if authHeader != "" {
67+
req.Header.Set("Authorization", authHeader)
68+
}
69+
router.ServeHTTP(w, req)
70+
return w
71+
}
72+
73+
func TestValidateProjectContext_ExpiredToken_Returns401(t *testing.T) {
74+
// Stand up a fake K8s API that returns 401 Unauthorized for all requests,
75+
// simulating an expired ServiceAccount token.
76+
k8s := fakeK8sAPI(http.StatusUnauthorized, `{"kind":"Status","apiVersion":"v1","status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`)
77+
defer k8s.Close()
78+
79+
handlers.BaseKubeConfig = &rest.Config{Host: k8s.URL}
80+
defer func() { handlers.BaseKubeConfig = nil }()
81+
82+
router := setupRouter()
83+
w := doRequest(router, "test-project", "Bearer expired-token")
84+
85+
assert.Equal(t, http.StatusUnauthorized, w.Code)
86+
87+
var body map[string]string
88+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
89+
assert.Equal(t, "Token expired or invalid", body["error"])
90+
}
91+
92+
func TestValidateProjectContext_ServerError_Returns500(t *testing.T) {
93+
// Fake K8s API returns 500 — should propagate as 500, not 401.
94+
k8s := fakeK8sAPI(http.StatusInternalServerError, `{"kind":"Status","apiVersion":"v1","status":"Failure","message":"internal error","reason":"InternalError","code":500}`)
95+
defer k8s.Close()
96+
97+
handlers.BaseKubeConfig = &rest.Config{Host: k8s.URL}
98+
defer func() { handlers.BaseKubeConfig = nil }()
99+
100+
router := setupRouter()
101+
w := doRequest(router, "test-project", "Bearer valid-token")
102+
103+
assert.Equal(t, http.StatusInternalServerError, w.Code)
104+
105+
var body map[string]string
106+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
107+
assert.Equal(t, "Failed to perform access review", body["error"])
108+
}
109+
110+
func TestValidateProjectContext_ValidToken_Allowed(t *testing.T) {
111+
k8s := fakeK8sAPI(http.StatusCreated, ssarAllowedBody())
112+
defer k8s.Close()
113+
114+
handlers.BaseKubeConfig = &rest.Config{Host: k8s.URL}
115+
defer func() { handlers.BaseKubeConfig = nil }()
116+
117+
router := setupRouter()
118+
w := doRequest(router, "test-project", "Bearer good-token")
119+
120+
assert.Equal(t, http.StatusOK, w.Code)
121+
122+
var body map[string]string
123+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
124+
assert.Equal(t, "test-project", body["project"])
125+
}
126+
127+
func TestValidateProjectContext_ValidToken_Denied(t *testing.T) {
128+
k8s := fakeK8sAPI(http.StatusCreated, ssarDeniedBody())
129+
defer k8s.Close()
130+
131+
handlers.BaseKubeConfig = &rest.Config{Host: k8s.URL}
132+
defer func() { handlers.BaseKubeConfig = nil }()
133+
134+
router := setupRouter()
135+
w := doRequest(router, "test-project", "Bearer good-token")
136+
137+
assert.Equal(t, http.StatusForbidden, w.Code)
138+
139+
var body map[string]string
140+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
141+
assert.Equal(t, "Unauthorized to access project", body["error"])
142+
}
143+
144+
func TestValidateProjectContext_NoToken_Returns401(t *testing.T) {
145+
router := setupRouter()
146+
w := doRequest(router, "test-project", "")
147+
148+
assert.Equal(t, http.StatusUnauthorized, w.Code)
149+
150+
var body map[string]string
151+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
152+
assert.Equal(t, "User token required", body["error"])
153+
}
154+
155+
func TestValidateProjectContext_InvalidProjectName_Returns400(t *testing.T) {
156+
handlers.BaseKubeConfig = &rest.Config{Host: "https://unused"}
157+
defer func() { handlers.BaseKubeConfig = nil }()
158+
159+
router := setupRouter()
160+
// Kubernetes names can't contain uppercase or special chars
161+
w := doRequest(router, "INVALID_PROJECT", "Bearer some-token")
162+
163+
assert.Equal(t, http.StatusBadRequest, w.Code)
164+
165+
var body map[string]string
166+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
167+
assert.Equal(t, "Invalid project name format", body["error"])
168+
}
169+
170+
func TestValidateProjectContext_Forbidden_Returns403(t *testing.T) {
171+
// K8s API returns 403 Forbidden — should propagate as 403, not 401 or 500.
172+
k8s := fakeK8sAPI(http.StatusForbidden, `{"kind":"Status","apiVersion":"v1","status":"Failure","message":"forbidden","reason":"Forbidden","code":403}`)
173+
defer k8s.Close()
174+
175+
handlers.BaseKubeConfig = &rest.Config{Host: k8s.URL}
176+
defer func() { handlers.BaseKubeConfig = nil }()
177+
178+
router := setupRouter()
179+
w := doRequest(router, "test-project", "Bearer some-token")
180+
181+
// K8s 403 on SSAR create is an API error (not an SSAR denial),
182+
// so it falls through to the non-unauthorized error path → 500.
183+
assert.Equal(t, http.StatusInternalServerError, w.Code)
184+
}

0 commit comments

Comments
 (0)