Skip to content

Commit 9eef17a

Browse files
committed
auth: enforce per-service-account ownership on GET session endpoints
Signed-off-by: Abhinav Singh <abhinavsingh717073@gmail.com>
1 parent bdfc95d commit 9eef17a

6 files changed

Lines changed: 77 additions & 19 deletions

File tree

pkg/common/types/sandbox.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ type SandboxInfo struct {
4242
// during ListInactiveSandboxes. It is intentionally excluded from JSON serialization.
4343
LastActivityAt time.Time `json:"-"`
4444
Status string `json:"status"`
45+
// CreatedBy holds the Kubernetes service account name (without the namespace prefix)
46+
// that created this sandbox. It is used by the GET handlers to enforce per-service-account
47+
// ownership: only the creating SA can retrieve its own session entrypoints.
48+
CreatedBy string `json:"createdBy,omitempty"`
4549
}
4650

4751
type SandboxEntryPoint struct {

pkg/workloadmanager/auth.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,24 @@ import (
4747
//
4848
// POST /v1/sandboxes (CreateSandbox):
4949
// - Any authenticated user can create sandboxes
50-
// - The creator's service account name is stored in the sandbox metadata
50+
// - The creator's service account name is stamped into SandboxInfo.CreatedBy
51+
// and persisted in the store at creation time
5152
//
5253
// GET /v1/sandboxes (ListSandboxes):
53-
// - Users can only list sandboxes they created
54+
// - Users can only list sandboxes they created (CreatedBy == serviceAccountName)
55+
// - For backward compatibility, sandboxes without a CreatedBy field are filtered
56+
// by namespace only (entries created before this field was introduced)
5457
//
5558
// GET /v1/sandboxes/{sandboxId} (GetSandbox):
56-
// - Users can only access sandboxes they created
57-
// - Access is checked via checkSandboxAccess() function
59+
// - Users can only access sandboxes they created (CreatedBy == serviceAccountName)
60+
// - Access is first checked by namespace, then by CreatedBy
61+
// - Returns 404 (not 403) for unauthorized or missing sessions to prevent
62+
// session ID enumeration attacks
5863
//
5964
// DELETE /v1/sandboxes/{sandboxId} (DeleteSandbox):
6065
// - Users can only delete sandboxes they created
61-
// - Access is checked via checkSandboxAccess() function
66+
// - Access is enforced by Kubernetes RBAC via the user's own dynamicClient;
67+
// the Kubernetes API itself rejects cross-account deletes
6268
//
6369
// CONNECT /v1/sandboxes/{sandboxId} (Tunnel):
6470
// - Users can only establish tunnels to sandboxes they created

pkg/workloadmanager/handlers.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func (s *Server) handleSandboxCreate(c *gin.Context, kind string) {
125125
namespace := sandbox.Namespace
126126

127127
dynamicClient := s.k8sClient.dynamicClient
128+
var createdBy string
128129
if s.config.EnableAuth {
129130
userDynamicClient, errExtractClient := s.extractUserK8sClient(c)
130131
if errExtractClient != nil {
@@ -133,6 +134,7 @@ func (s *Server) handleSandboxCreate(c *gin.Context, kind string) {
133134
return
134135
}
135136
dynamicClient = userDynamicClient
137+
_, _, _, createdBy = extractUserInfo(c)
136138
}
137139

138140
// CRITICAL: Register watcher BEFORE creating sandbox
@@ -141,7 +143,7 @@ func (s *Server) handleSandboxCreate(c *gin.Context, kind string) {
141143
// Ensure cleanup is called when function returns to prevent memory leak
142144
defer s.sandboxController.UnWatchSandbox(namespace, sandboxName)
143145

144-
response, err := s.createSandbox(c.Request.Context(), dynamicClient, sandbox, sandboxClaim, sandboxEntry, resultChan)
146+
response, err := s.createSandbox(c.Request.Context(), dynamicClient, sandbox, sandboxClaim, sandboxEntry, resultChan, createdBy)
145147
if err != nil {
146148
// Client disconnected — abort with 499 so logs/metrics reflect the cancellation.
147149
if errors.Is(err, context.Canceled) {
@@ -196,8 +198,8 @@ func (s *Server) createK8sResources(ctx context.Context, dynamicClient dynamic.I
196198
}
197199

198200
// createSandbox performs sandbox creation and returns the response payload or an error with an HTTP status code.
199-
func (s *Server) createSandbox(ctx context.Context, dynamicClient dynamic.Interface, sandbox *sandboxv1alpha1.Sandbox, sandboxClaim *extensionsv1alpha1.SandboxClaim, sandboxEntry *sandboxEntry, resultChan <-chan SandboxStatusUpdate) (*types.CreateSandboxResponse, error) {
200-
placeholder := buildSandboxPlaceHolder(sandbox, sandboxEntry)
201+
func (s *Server) createSandbox(ctx context.Context, dynamicClient dynamic.Interface, sandbox *sandboxv1alpha1.Sandbox, sandboxClaim *extensionsv1alpha1.SandboxClaim, sandboxEntry *sandboxEntry, resultChan <-chan SandboxStatusUpdate, createdBy string) (*types.CreateSandboxResponse, error) {
202+
placeholder := buildSandboxPlaceHolder(sandbox, sandboxEntry, createdBy)
201203
if err := s.storeClient.StoreSandbox(ctx, placeholder); err != nil {
202204
if isContextError(err) {
203205
return nil, err
@@ -261,7 +263,7 @@ func (s *Server) createSandbox(ctx context.Context, dynamicClient dynamic.Interf
261263
return nil, api.NewInternalError(fmt.Errorf("failed to verify sandbox %s/%s entrypoints: %w", sandbox.Namespace, sandbox.Name, err))
262264
}
263265

264-
storeCacheInfo := buildSandboxInfo(createdSandbox, podIP, sandboxEntry)
266+
storeCacheInfo := buildSandboxInfo(createdSandbox, podIP, sandboxEntry, createdBy)
265267

266268
response := &types.CreateSandboxResponse{
267269
Kind: storeCacheInfo.Kind,
@@ -329,12 +331,19 @@ func (s *Server) handleGetSandbox(c *gin.Context, kind string) {
329331
}
330332

331333
if s.config.EnableAuth {
332-
_, userNamespace, _, _ := extractUserInfo(c)
334+
_, userNamespace, _, serviceAccountName := extractUserInfo(c)
333335
if sandboxInfo.SandboxNamespace != userNamespace {
334336
klog.Warningf("unauthorized GET attempt to session %s by user in namespace %s", sessionID, userNamespace)
335337
respondError(c, http.StatusNotFound, fmt.Sprintf("Session ID %s not found", sessionID))
336338
return
337339
}
340+
// Enforce per-service-account ownership: only the creating SA can retrieve its session.
341+
// Returns 404 (not 403) to prevent session ID enumeration.
342+
if sandboxInfo.CreatedBy != "" && sandboxInfo.CreatedBy != serviceAccountName {
343+
klog.Warningf("unauthorized GET attempt to session %s by service account %s (owner: %s)", sessionID, serviceAccountName, sandboxInfo.CreatedBy)
344+
respondError(c, http.StatusNotFound, fmt.Sprintf("Session ID %s not found", sessionID))
345+
return
346+
}
338347
}
339348

340349
respondJSON(c, http.StatusOK, sandboxInfo)
@@ -354,13 +363,20 @@ func (s *Server) handleListSandboxes(c *gin.Context, kind string) {
354363
return
355364
}
356365

357-
_, userNamespace, _, _ := extractUserInfo(c)
366+
_, userNamespace, _, serviceAccountName := extractUserInfo(c)
358367
// Filter in-place to avoid a new allocation.
368+
// When CreatedBy is set, enforce per-SA ownership (documented intent in auth.go).
369+
// When CreatedBy is empty (legacy entries created before this field was added),
370+
// fall back to namespace-scoped filtering so existing sessions remain accessible.
359371
filtered := sandboxes[:0]
360372
for _, sb := range sandboxes {
361-
if sb.SandboxNamespace == userNamespace {
362-
filtered = append(filtered, sb)
373+
if sb.SandboxNamespace != userNamespace {
374+
continue
375+
}
376+
if sb.CreatedBy != "" && sb.CreatedBy != serviceAccountName {
377+
continue
363378
}
379+
filtered = append(filtered, sb)
364380
}
365381
respondJSON(c, http.StatusOK, filtered)
366382
}

pkg/workloadmanager/handlers_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ func TestServerCreateSandbox(t *testing.T) {
245245
claim = &extensionsv1alpha1.SandboxClaim{ObjectMeta: metav1.ObjectMeta{Name: sb.Name, Namespace: sb.Namespace}}
246246
}
247247

248-
resp, err := server.createSandbox(context.Background(), nil, sb, claim, makeEntry(), resultChan)
248+
resp, err := server.createSandbox(context.Background(), nil, sb, claim, makeEntry(), resultChan, "")
249249

250250
require.Equal(t, tt.expectCreateCalls, createCalls, "createSandbox call count")
251251
require.Equal(t, tt.expectClaimCalls, claimCalls, "createSandboxClaim call count")
@@ -445,7 +445,7 @@ func TestHandleSandboxCreate(t *testing.T) {
445445
})
446446

447447
createCalls := 0
448-
patches.ApplyPrivateMethod(reflect.TypeOf(fakeServer), "createSandbox", func(_ *Server, _ context.Context, _ dynamic.Interface, _ *sandboxv1alpha1.Sandbox, _ *extensionsv1alpha1.SandboxClaim, _ *sandboxEntry, _ <-chan SandboxStatusUpdate) (*types.CreateSandboxResponse, error) {
448+
patches.ApplyPrivateMethod(reflect.TypeOf(fakeServer), "createSandbox", func(_ *Server, _ context.Context, _ dynamic.Interface, _ *sandboxv1alpha1.Sandbox, _ *extensionsv1alpha1.SandboxClaim, _ *sandboxEntry, _ <-chan SandboxStatusUpdate, _ string) (*types.CreateSandboxResponse, error) {
449449
createCalls++
450450
if tc.createErr != nil {
451451
return nil, tc.createErr
@@ -542,6 +542,20 @@ func TestHandleGetSandbox(t *testing.T) {
542542
expectStatus: http.StatusNotFound,
543543
expectMessage: "Session ID sess-auth-fail not found",
544544
},
545+
{
546+
name: "forbidden with auth matching namespace but different service account",
547+
sessionID: "sess-wrong-sa",
548+
enableAuth: true,
549+
userNamespace: "user-ns",
550+
storeResult: &types.SandboxInfo{
551+
SessionID: "sess-wrong-sa",
552+
SandboxNamespace: "user-ns",
553+
Kind: types.AgentRuntimeKind,
554+
CreatedBy: "other-sa",
555+
},
556+
expectStatus: http.StatusNotFound,
557+
expectMessage: "Session ID sess-wrong-sa not found",
558+
},
545559
{
546560
name: "wrong kind",
547561
sessionID: "sess-wrong-kind",
@@ -652,6 +666,22 @@ func TestHandleListSandboxes(t *testing.T) {
652666
expectStatus: http.StatusOK,
653667
expectCount: 1,
654668
},
669+
{
670+
name: "success with auth filters by service account",
671+
kind: types.AgentRuntimeKind,
672+
enableAuth: true,
673+
userNamespace: "user-ns",
674+
storeResult: []*types.SandboxInfo{
675+
// Same namespace, created by the requesting SA — should be included
676+
{SessionID: "1", SandboxNamespace: "user-ns", Kind: types.AgentRuntimeKind, CreatedBy: "mock-sa"},
677+
// Same namespace, different SA — should be excluded
678+
{SessionID: "2", SandboxNamespace: "user-ns", Kind: types.AgentRuntimeKind, CreatedBy: "other-sa"},
679+
// Same namespace, no CreatedBy (legacy) — should be included (backward compat)
680+
{SessionID: "3", SandboxNamespace: "user-ns", Kind: types.AgentRuntimeKind, CreatedBy: ""},
681+
},
682+
expectStatus: http.StatusOK,
683+
expectCount: 2,
684+
},
655685
}
656686

657687
for _, tt := range tests {

pkg/workloadmanager/sandbox_helper.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ var sandboxEntrypointDial = func(ctx context.Context, endpoint string, timeout t
4747
return conn.Close()
4848
}
4949

50-
func buildSandboxPlaceHolder(sandboxCR *sandboxv1alpha1.Sandbox, entry *sandboxEntry) *types.SandboxInfo {
50+
func buildSandboxPlaceHolder(sandboxCR *sandboxv1alpha1.Sandbox, entry *sandboxEntry, createdBy string) *types.SandboxInfo {
5151
var expiresAt time.Time
5252
if sandboxCR.Spec.Lifecycle.ShutdownTime != nil {
5353
expiresAt = sandboxCR.Spec.Lifecycle.ShutdownTime.Time
@@ -66,10 +66,11 @@ func buildSandboxPlaceHolder(sandboxCR *sandboxv1alpha1.Sandbox, entry *sandboxE
6666
ExpiresAt: expiresAt,
6767
Status: "creating",
6868
IdleTimeout: metav1.Duration{Duration: idleTimeout},
69+
CreatedBy: createdBy,
6970
}
7071
}
7172

72-
func buildSandboxInfo(sandbox *sandboxv1alpha1.Sandbox, podIP string, entry *sandboxEntry) *types.SandboxInfo {
73+
func buildSandboxInfo(sandbox *sandboxv1alpha1.Sandbox, podIP string, entry *sandboxEntry, createdBy string) *types.SandboxInfo {
7374
createdAt := sandbox.GetCreationTimestamp().Time
7475
expiresAt := createdAt.Add(DefaultSandboxTTL)
7576
if sandbox.Spec.Lifecycle.ShutdownTime != nil {
@@ -98,6 +99,7 @@ func buildSandboxInfo(sandbox *sandboxv1alpha1.Sandbox, podIP string, entry *san
9899
ExpiresAt: expiresAt,
99100
Status: getSandboxStatus(sandbox),
100101
IdleTimeout: metav1.Duration{Duration: idleTimeout},
102+
CreatedBy: createdBy,
101103
}
102104
}
103105

pkg/workloadmanager/sandbox_helper_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ func TestBuildSandboxPlaceHolder_TableDriven(t *testing.T) {
151151
for _, tt := range tests {
152152
t.Run(tt.name, func(t *testing.T) {
153153
sandbox := tt.setupSandbox()
154-
result := buildSandboxPlaceHolder(sandbox, tt.entry)
154+
result := buildSandboxPlaceHolder(sandbox, tt.entry, "")
155155
tt.validate(t, result)
156156
})
157157
}
@@ -322,7 +322,7 @@ func TestBuildSandboxInfo_TableDriven(t *testing.T) {
322322
for _, tt := range tests {
323323
t.Run(tt.name, func(t *testing.T) {
324324
sandbox := tt.setupSandbox()
325-
result := buildSandboxInfo(sandbox, tt.podIP, tt.entry)
325+
result := buildSandboxInfo(sandbox, tt.podIP, tt.entry, "")
326326
tt.validateResult(t, result)
327327
})
328328
}

0 commit comments

Comments
 (0)