Skip to content

Commit c7e71a4

Browse files
authored
fix: portal nil S3Client panic, admin role prefix, and deep link URL (#191)
* fix: portal nil S3Client panic, admin role prefix, and deep link URL - Guard getAssetContent and fetchPublicAsset against nil S3Client, returning 503 instead of panicking when s3_connection is not configured - Use AdminRoles from persona config for is_admin check instead of hardcoded "admin" string, fixing mismatch with prefixed roles like "dp_admin" - Fix save_artifact deep link from /artifacts/{id} to /portal/assets/{id} * fix: fail early on nil S3Client in portal toolkit write paths Previously, handleSaveArtifact and uploadContentUpdate silently skipped the S3 upload when s3Client was nil, creating ghost assets in the store pointing to non-existent S3 content. Now both paths return a clear "content storage not configured" error instead.
1 parent 151e680 commit c7e71a4

File tree

7 files changed

+189
-17
lines changed

7 files changed

+189
-17
lines changed

cmd/mcp-data-platform/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,13 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform) {
489489
}
490490
portalAuth := portal.NewAuthenticator(p.Authenticator(), portalAuthOpts...)
491491

492+
var adminRoles []string
493+
if pr := p.PersonaRegistry(); pr != nil {
494+
if persona, ok := pr.Get(p.Config().Admin.Persona); ok {
495+
adminRoles = persona.Roles
496+
}
497+
}
498+
492499
deps := portal.Deps{
493500
AssetStore: p.PortalAssetStore(),
494501
ShareStore: p.PortalShareStore(),
@@ -500,6 +507,7 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform) {
500507
BurstSize: p.Config().Portal.RateLimit.BurstSize,
501508
},
502509
OIDCEnabled: p.BrowserSessionFlow() != nil,
510+
AdminRoles: adminRoles,
503511
}
504512

505513
handler := portal.NewHandler(deps, portal.RequirePortalAuth(portalAuth))

pkg/portal/handler.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Deps struct {
3030
PublicBaseURL string
3131
RateLimit RateLimitConfig
3232
OIDCEnabled bool
33+
AdminRoles []string // roles that grant admin access in the portal
3334
}
3435

3536
// Handler provides portal REST API endpoints.
@@ -97,7 +98,7 @@ type meResponse struct {
9798
IsAdmin bool `json:"is_admin"`
9899
}
99100

100-
func (*Handler) getMe(w http.ResponseWriter, r *http.Request) {
101+
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
101102
user := GetUser(r.Context())
102103
if user == nil {
103104
writeError(w, http.StatusUnauthorized, errAuthRequired)
@@ -107,7 +108,7 @@ func (*Handler) getMe(w http.ResponseWriter, r *http.Request) {
107108
writeJSON(w, http.StatusOK, meResponse{
108109
UserID: user.UserID,
109110
Roles: user.Roles,
110-
IsAdmin: slices.Contains(user.Roles, "admin"),
111+
IsAdmin: hasAnyRole(user.Roles, h.deps.AdminRoles),
111112
})
112113
}
113114

@@ -207,6 +208,11 @@ func (h *Handler) getAssetContent(w http.ResponseWriter, r *http.Request) {
207208
}
208209
}
209210

211+
if h.deps.S3Client == nil {
212+
writeError(w, http.StatusServiceUnavailable, "content storage not configured")
213+
return
214+
}
215+
210216
data, contentType, err := h.deps.S3Client.GetObject(r.Context(), asset.S3Bucket, asset.S3Key)
211217
if err != nil {
212218
writeError(w, http.StatusInternalServerError, "failed to retrieve content")
@@ -510,6 +516,16 @@ func generateToken() (string, error) {
510516
return hex.EncodeToString(b), nil
511517
}
512518

519+
// hasAnyRole returns true if any role in userRoles is also in targetRoles.
520+
func hasAnyRole(userRoles, targetRoles []string) bool {
521+
for _, r := range userRoles {
522+
if slices.Contains(targetRoles, r) {
523+
return true
524+
}
525+
}
526+
return false
527+
}
528+
513529
// isSharedWithUser checks whether an asset has been shared with a specific user.
514530
func (h *Handler) isSharedWithUser(r *http.Request, assetID, userID string) bool {
515531
shares, err := h.deps.ShareStore.ListByAsset(r.Context(), assetID)

pkg/portal/handler_test.go

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,23 @@ func TestGetAssetContentNoUser(t *testing.T) {
449449
assert.Equal(t, http.StatusUnauthorized, w.Code)
450450
}
451451

452+
func TestGetAssetContentNilS3Client(t *testing.T) {
453+
asset := &Asset{ID: "a1", OwnerID: "u1", S3Bucket: "b", S3Key: "k"}
454+
h := NewHandler(Deps{
455+
AssetStore: &mockAssetStore{getAsset: asset},
456+
ShareStore: &mockShareStore{},
457+
S3Client: nil, // no S3 configured
458+
S3Bucket: "test-bucket",
459+
}, testAuthMiddleware(&User{UserID: "u1"}))
460+
461+
req := httptest.NewRequest("GET", "/api/v1/portal/assets/a1/content", http.NoBody)
462+
w := httptest.NewRecorder()
463+
h.ServeHTTP(w, req)
464+
465+
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
466+
assert.Contains(t, w.Body.String(), "content storage not configured")
467+
}
468+
452469
// --- updateAsset ---
453470

454471
func TestUpdateAssetSuccess(t *testing.T) {
@@ -1107,6 +1124,28 @@ func TestListSharedWithMeNoUser(t *testing.T) {
11071124
assert.Equal(t, http.StatusUnauthorized, w.Code)
11081125
}
11091126

1127+
// --- hasAnyRole ---
1128+
1129+
func TestHasAnyRole(t *testing.T) {
1130+
tests := []struct {
1131+
name string
1132+
userRoles []string
1133+
targetRoles []string
1134+
expected bool
1135+
}{
1136+
{"match", []string{"dp_admin", "dp_analyst"}, []string{"dp_admin"}, true},
1137+
{"no match", []string{"dp_analyst"}, []string{"dp_admin"}, false},
1138+
{"empty user roles", nil, []string{"dp_admin"}, false},
1139+
{"empty target roles", []string{"dp_admin"}, nil, false},
1140+
{"both empty", nil, nil, false},
1141+
}
1142+
for _, tt := range tests {
1143+
t.Run(tt.name, func(t *testing.T) {
1144+
assert.Equal(t, tt.expected, hasAnyRole(tt.userRoles, tt.targetRoles))
1145+
})
1146+
}
1147+
}
1148+
11101149
// --- intParam ---
11111150

11121151
func TestIntParam(t *testing.T) {
@@ -1221,8 +1260,14 @@ func TestIsSharedWithUserError(t *testing.T) {
12211260
// --- Me handler tests ---
12221261

12231262
func TestGetMeSuccess(t *testing.T) {
1224-
user := &User{UserID: "user-42", Roles: []string{"admin", "analyst"}}
1225-
h := newTestHandler(&mockAssetStore{}, &mockShareStore{}, &mockS3Client{}, user)
1263+
user := &User{UserID: "user-42", Roles: []string{"dp_admin", "analyst"}}
1264+
h := NewHandler(Deps{
1265+
AssetStore: &mockAssetStore{},
1266+
ShareStore: &mockShareStore{},
1267+
S3Client: &mockS3Client{},
1268+
S3Bucket: "test-bucket",
1269+
AdminRoles: []string{"dp_admin"},
1270+
}, testAuthMiddleware(user))
12261271

12271272
req := httptest.NewRequest("GET", "/api/v1/portal/me", http.NoBody)
12281273
w := httptest.NewRecorder()
@@ -1234,7 +1279,28 @@ func TestGetMeSuccess(t *testing.T) {
12341279
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
12351280
assert.Equal(t, "user-42", resp.UserID)
12361281
assert.True(t, resp.IsAdmin)
1237-
assert.Contains(t, resp.Roles, "admin")
1282+
assert.Contains(t, resp.Roles, "dp_admin")
1283+
}
1284+
1285+
func TestGetMeNonAdminWithPrefixedRoles(t *testing.T) {
1286+
// Verify that roles like "dp_analyst" do NOT match admin when AdminRoles is ["dp_admin"]
1287+
user := &User{UserID: "user-99", Roles: []string{"dp_analyst"}}
1288+
h := NewHandler(Deps{
1289+
AssetStore: &mockAssetStore{},
1290+
ShareStore: &mockShareStore{},
1291+
S3Client: &mockS3Client{},
1292+
AdminRoles: []string{"dp_admin"},
1293+
}, testAuthMiddleware(user))
1294+
1295+
req := httptest.NewRequest("GET", "/api/v1/portal/me", http.NoBody)
1296+
w := httptest.NewRecorder()
1297+
h.ServeHTTP(w, req)
1298+
1299+
assert.Equal(t, http.StatusOK, w.Code)
1300+
1301+
var resp meResponse
1302+
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
1303+
assert.False(t, resp.IsAdmin)
12381304
}
12391305

12401306
func TestGetMeNonAdmin(t *testing.T) {

pkg/portal/public.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ func (h *Handler) fetchPublicAsset(r *http.Request, assetID string) (*Asset, []b
105105
return nil, nil, &publicAssetError{Message: "This asset has been deleted.", Status: http.StatusGone}
106106
}
107107

108+
if h.deps.S3Client == nil {
109+
return nil, nil, &publicAssetError{Message: "Content storage not configured.", Status: http.StatusServiceUnavailable}
110+
}
111+
108112
data, _, err := h.deps.S3Client.GetObject(r.Context(), asset.S3Bucket, asset.S3Key)
109113
if err != nil {
110114
slog.Error("public view: failed to fetch content", "error", err, "asset_id", asset.ID) //nolint:gosec // structured log, not user-facing

pkg/portal/public_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ func TestPublicViewAssetDeleted(t *testing.T) {
118118
assert.Equal(t, http.StatusGone, w.Code)
119119
}
120120

121+
func TestPublicViewNilS3Client(t *testing.T) {
122+
share := &Share{ID: "s1", AssetID: "a1", Token: "tok1"}
123+
asset := &Asset{ID: "a1"}
124+
h := NewHandler(Deps{
125+
AssetStore: &mockAssetStore{getAsset: asset},
126+
ShareStore: &mockShareStore{getByTokenRes: share},
127+
S3Client: nil, // no S3 configured
128+
}, nil)
129+
130+
req := httptest.NewRequest("GET", "/portal/view/tok1", http.NoBody)
131+
w := httptest.NewRecorder()
132+
h.ServeHTTP(w, req)
133+
134+
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
135+
}
136+
121137
func TestPublicViewS3Error(t *testing.T) {
122138
share := &Share{ID: "s1", AssetID: "a1", Token: "tok1"}
123139
asset := &Asset{ID: "a1"}

pkg/toolkits/portal/toolkit.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,11 @@ func (t *Toolkit) handleSaveArtifact(ctx context.Context, _ *mcp.CallToolRequest
173173

174174
s3Key := t.buildS3Key(userID, assetID, input.ContentType)
175175

176-
if t.s3Client != nil {
177-
if err := t.s3Client.PutObject(ctx, t.s3Bucket, s3Key, []byte(input.Content), input.ContentType); err != nil {
178-
return errorResult("failed to upload content: " + err.Error()), nil, nil //nolint:nilerr // MCP protocol
179-
}
176+
if t.s3Client == nil {
177+
return errorResult("content storage not configured"), nil, nil
178+
}
179+
if err := t.s3Client.PutObject(ctx, t.s3Bucket, s3Key, []byte(input.Content), input.ContentType); err != nil {
180+
return errorResult("failed to upload content: " + err.Error()), nil, nil //nolint:nilerr // MCP protocol
180181
}
181182

182183
prov := buildProvenance(ctx, userID, sessionID)
@@ -314,10 +315,11 @@ func (t *Toolkit) uploadContentUpdate(ctx context.Context, asset *portal.Asset,
314315
ext := extensionForContentType(ct)
315316
s3Key := path.Join(t.s3Prefix, asset.OwnerID, asset.ID, "content"+ext)
316317

317-
if t.s3Client != nil {
318-
if err := t.s3Client.PutObject(ctx, t.s3Bucket, s3Key, []byte(input.Content), ct); err != nil {
319-
return fmt.Errorf("s3 put: %w", err)
320-
}
318+
if t.s3Client == nil {
319+
return fmt.Errorf("content storage not configured")
320+
}
321+
if err := t.s3Client.PutObject(ctx, t.s3Bucket, s3Key, []byte(input.Content), ct); err != nil {
322+
return fmt.Errorf("s3 put: %w", err)
321323
}
322324
updates.S3Key = s3Key
323325
updates.SizeBytes = int64(len(input.Content))
@@ -393,7 +395,7 @@ func (t *Toolkit) buildSaveOutput(assetID string, prov portal.Provenance) saveAr
393395
ToolCallsRecorded: len(prov.ToolCalls),
394396
}
395397
if t.baseURL != "" {
396-
out.PortalURL = t.baseURL + "/artifacts/" + assetID
398+
out.PortalURL = t.baseURL + "/portal/assets/" + assetID
397399
}
398400
return out
399401
}

pkg/toolkits/portal/toolkit_test.go

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func TestSaveArtifact_ValidationErrors(t *testing.T) {
120120

121121
func TestSaveArtifact_WithProvenance(t *testing.T) {
122122
store := newInMemoryAssetStore()
123-
tk := New(Config{Name: "test", AssetStore: store, S3Bucket: "bucket"})
123+
tk := New(Config{Name: "test", AssetStore: store, S3Client: &mockS3Client{}, S3Bucket: "bucket"})
124124

125125
provCalls := []middleware.ProvenanceToolCall{
126126
{ToolName: "trino_query", Timestamp: "2024-01-01T00:00:00Z", Summary: "SELECT 1"},
@@ -305,6 +305,26 @@ func TestManageArtifact_MissingAssetID(t *testing.T) {
305305
}
306306
}
307307

308+
func TestBuildSaveOutput(t *testing.T) {
309+
tk := New(Config{Name: "test", BaseURL: "https://example.com", S3Bucket: "bucket"})
310+
out := tk.buildSaveOutput("abc123", portal.Provenance{
311+
ToolCalls: []portal.ProvenanceToolCall{{ToolName: "trino_query"}},
312+
})
313+
314+
assert.Equal(t, "abc123", out.AssetID)
315+
assert.Equal(t, "https://example.com/portal/assets/abc123", out.PortalURL)
316+
assert.True(t, out.ProvenanceCaptured)
317+
assert.Equal(t, 1, out.ToolCallsRecorded)
318+
}
319+
320+
func TestBuildSaveOutputNoBaseURL(t *testing.T) {
321+
tk := New(Config{Name: "test", S3Bucket: "bucket"})
322+
out := tk.buildSaveOutput("abc123", portal.Provenance{})
323+
324+
assert.Empty(t, out.PortalURL)
325+
assert.False(t, out.ProvenanceCaptured)
326+
}
327+
308328
func TestExtensionForContentType(t *testing.T) {
309329
tests := []struct {
310330
ct string
@@ -444,7 +464,7 @@ func TestSaveArtifact_S3Error(t *testing.T) {
444464

445465
func TestSaveArtifact_NoContext(t *testing.T) {
446466
store := newInMemoryAssetStore()
447-
tk := New(Config{Name: "test", AssetStore: store, S3Bucket: "bucket"})
467+
tk := New(Config{Name: "test", AssetStore: store, S3Client: &mockS3Client{}, S3Bucket: "bucket"})
448468

449469
// Call without PlatformContext — should default to anonymous.
450470
input := saveArtifactInput{
@@ -456,6 +476,25 @@ func TestSaveArtifact_NoContext(t *testing.T) {
456476
assert.False(t, result.IsError)
457477
}
458478

479+
func TestSaveArtifact_NilS3Client(t *testing.T) {
480+
tk := New(Config{Name: "test", AssetStore: newInMemoryAssetStore(), S3Client: nil, S3Bucket: "bucket"})
481+
482+
ctx := middleware.WithPlatformContext(context.Background(), &middleware.PlatformContext{
483+
UserID: "user1", SessionID: "sess1",
484+
})
485+
486+
input := saveArtifactInput{
487+
Name: "Test", Content: "<div/>", ContentType: "text/html",
488+
}
489+
490+
result, _, err := tk.handleSaveArtifact(ctx, nil, input)
491+
require.NoError(t, err)
492+
assert.True(t, result.IsError)
493+
tc, ok := result.Content[0].(*mcp.TextContent) //nolint:errcheck // test assertion
494+
require.True(t, ok)
495+
assert.Contains(t, tc.Text, "content storage not configured")
496+
}
497+
459498
func TestManageArtifact_DeleteWrongOwner(t *testing.T) {
460499
store := newInMemoryAssetStore()
461500
_ = store.Insert(context.Background(), portal.Asset{
@@ -591,7 +630,7 @@ func (s *errorAssetStore) Update(_ context.Context, _ string, _ portal.AssetUpda
591630
func TestSaveArtifact_StoreInsertError(t *testing.T) {
592631
store := &errorAssetStore{insertErr: notFoundError{}}
593632
store.assets = make(map[string]portal.Asset)
594-
tk := New(Config{Name: "test", AssetStore: store, S3Bucket: "bucket"})
633+
tk := New(Config{Name: "test", AssetStore: store, S3Client: &mockS3Client{}, S3Bucket: "bucket"})
595634

596635
ctx := middleware.WithPlatformContext(context.Background(), &middleware.PlatformContext{
597636
UserID: "user1", SessionID: "sess1",
@@ -647,6 +686,27 @@ func TestManageArtifact_UpdateStoreError(t *testing.T) {
647686
assert.Contains(t, tc.Text, "failed to update asset")
648687
}
649688

689+
func TestManageArtifact_UpdateNilS3Client(t *testing.T) {
690+
store := newInMemoryAssetStore()
691+
_ = store.Insert(context.Background(), portal.Asset{
692+
ID: "a1", OwnerID: "user1", Name: "Test", ContentType: "text/html",
693+
Tags: []string{}, Provenance: portal.Provenance{},
694+
})
695+
696+
tk := New(Config{Name: "test", AssetStore: store, S3Client: nil, S3Bucket: "bucket"})
697+
698+
ctx := middleware.WithPlatformContext(context.Background(), &middleware.PlatformContext{UserID: "user1"})
699+
700+
result, _, err := tk.handleManageArtifact(ctx, nil, manageArtifactInput{
701+
Action: "update", AssetID: "a1", Content: "<div>Updated</div>",
702+
})
703+
require.NoError(t, err)
704+
assert.True(t, result.IsError)
705+
tc, ok := result.Content[0].(*mcp.TextContent) //nolint:errcheck // test assertion
706+
require.True(t, ok)
707+
assert.Contains(t, tc.Text, "content storage not configured")
708+
}
709+
650710
func TestManageArtifact_UpdateWithContentError(t *testing.T) {
651711
store := newInMemoryAssetStore()
652712
_ = store.Insert(context.Background(), portal.Asset{

0 commit comments

Comments
 (0)