Skip to content

Commit ac5e171

Browse files
committed
fix(api): reject invalid external owner inputs
1 parent dd44a4c commit ac5e171

4 files changed

Lines changed: 101 additions & 4 deletions

File tree

api/create_request_normalization.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (s *server) normalizeCreateRequest(_ context.Context, principal principal,
9494
return nil, newCreateRequestError(http.StatusBadRequest, err)
9595
}
9696
if body.OwnerRef != nil && strings.EqualFold(strings.TrimSpace(body.OwnerRef.Type), "external") {
97-
if !principalCanUseProvisionerFlow(principal) {
97+
if !principal.isService() {
9898
return nil, newCreateRequestError(http.StatusForbidden, errForbidden)
9999
}
100100
} else {

api/external_owner_resolution.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,17 @@ func (c externalOwnerConfig) resolve(ctx context.Context, principal principal, r
247247
subject: normalized.Subject,
248248
}
249249
}
250-
if len(policy.AllowedTenants) > 0 && normalized.Tenant != "" {
250+
if len(policy.AllowedTenants) > 0 {
251+
if normalized.Tenant == "" {
252+
return externalOwnerResolution{}, externalOwnerResolutionError{
253+
status: http.StatusForbidden,
254+
code: "external_identity_forbidden",
255+
message: "tenant is required for this principal",
256+
provider: normalized.Provider,
257+
tenant: normalized.Tenant,
258+
subject: normalized.Subject,
259+
}
260+
}
251261
if _, ok := policy.AllowedTenants[normalized.Tenant]; !ok {
252262
return externalOwnerResolution{}, externalOwnerResolutionError{
253263
status: http.StatusForbidden,

api/main_create_external_owner_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"net/http"
89
"net/http/httptest"
910
"strings"
@@ -294,6 +295,79 @@ func TestCreateSpritzRejectsExternalOwnerResolutionWithoutCreateScopes(t *testin
294295
}
295296
}
296297

298+
func TestCreateSpritzRejectsExternalOwnerForAdminCallers(t *testing.T) {
299+
s := newCreateSpritzTestServer(t)
300+
configureProvisionerTestServer(s)
301+
resolverCalls := 0
302+
configureExternalOwnerTestServer(s, fakeExternalOwnerResolver{
303+
resolve: func(_ context.Context, _ externalOwnerPolicy, _ principal, _ ownerRef, _ string) (externalOwnerResolution, error) {
304+
resolverCalls++
305+
return externalOwnerResolution{
306+
Status: externalOwnerResolved,
307+
OwnerID: "user-123",
308+
}, nil
309+
},
310+
})
311+
e := newCreateSpritzAPI(t, s)
312+
313+
body := []byte(`{"presetId":"openclaw","ownerRef":{"type":"external","provider":"msteams","tenant":"72f988bf-86f1-41af-91ab-2d7cd011db47","subject":"6f0f9d4f-9b0e-4d52-8c3a-ef0fd64b9b9f"},"idempotencyKey":"teams-admin"}`)
314+
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
315+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
316+
req.Header.Set("X-Spritz-User-Id", "admin-1")
317+
req.Header.Set("X-Spritz-Principal-Type", "admin")
318+
rec := httptest.NewRecorder()
319+
320+
e.ServeHTTP(rec, req)
321+
322+
if rec.Code != http.StatusForbidden {
323+
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
324+
}
325+
if resolverCalls != 0 {
326+
t.Fatalf("expected resolver to be skipped for admin callers, got %d calls", resolverCalls)
327+
}
328+
}
329+
330+
func TestExternalOwnerResolveRequiresTenantWhenTenantAllowlistIsConfigured(t *testing.T) {
331+
config := externalOwnerConfig{
332+
subjectHashKey: []byte("test-external-owner-secret"),
333+
policies: map[string]externalOwnerPolicy{
334+
"zenobot": {
335+
PrincipalID: "zenobot",
336+
Issuer: "zenobot",
337+
URL: "http://resolver.example.com/v1/external-owners/resolve",
338+
AllowedProviders: map[string]struct{}{
339+
"slack": {},
340+
},
341+
AllowedTenants: map[string]struct{}{
342+
"enterprise-1": {},
343+
},
344+
},
345+
},
346+
resolver: fakeExternalOwnerResolver{
347+
resolve: func(_ context.Context, _ externalOwnerPolicy, _ principal, _ ownerRef, _ string) (externalOwnerResolution, error) {
348+
t.Fatal("expected resolver call to be blocked when tenant is missing")
349+
return externalOwnerResolution{}, nil
350+
},
351+
},
352+
}
353+
354+
_, err := config.resolve(context.Background(), principal{ID: "zenobot", Type: principalTypeService}, ownerRef{
355+
Type: "external",
356+
Provider: "slack",
357+
Subject: "U123456",
358+
}, "")
359+
if err == nil {
360+
t.Fatal("expected resolve to fail when tenant allowlist is configured but tenant is missing")
361+
}
362+
var resolutionErr externalOwnerResolutionError
363+
if !errors.As(err, &resolutionErr) {
364+
t.Fatalf("expected externalOwnerResolutionError, got %T", err)
365+
}
366+
if resolutionErr.code != "external_identity_forbidden" {
367+
t.Fatalf("expected external_identity_forbidden, got %q", resolutionErr.code)
368+
}
369+
}
370+
297371
func TestCreateRequestFingerprintCanonicalizesEquivalentOwnerInputs(t *testing.T) {
298372
directFingerprint, err := createRequestFingerprint(createRequest{
299373
OwnerID: "user-123",
@@ -372,3 +446,16 @@ func TestNormalizeCreateOwnerSupportsOwnerRefOwner(t *testing.T) {
372446
t.Fatalf("expected body ownerId to be populated from ownerRef, got %q", body.OwnerID)
373447
}
374448
}
449+
450+
func TestNormalizeCreateOwnerRejectsOwnerRefWithoutType(t *testing.T) {
451+
body := &createRequest{
452+
OwnerRef: &ownerRef{ID: "user-123"},
453+
}
454+
_, err := normalizeCreateOwnerRequest(body, principal{ID: "user-123", Type: principalTypeHuman}, true)
455+
if err == nil {
456+
t.Fatal("expected normalizeCreateOwnerRequest to reject ownerRef without type")
457+
}
458+
if !strings.Contains(err.Error(), "ownerRef.type is required") {
459+
t.Fatalf("expected ownerRef.type validation error, got %v", err)
460+
}
461+
}

api/provisioning.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ func normalizeCreateOwnerRequest(body *createRequest, principal principal, authE
220220
ref.ID = strings.TrimSpace(ref.ID)
221221
switch ref.Type {
222222
case "":
223+
return owner, fmt.Errorf("ownerRef.type is required")
223224
case "owner":
224225
if ref.ID == "" {
225226
return owner, fmt.Errorf("ownerRef.id is required when ownerRef.type=owner")
@@ -888,8 +889,7 @@ func canonicalOwnerFingerprintPayload(ownerID string, ref *ownerRef, resolvedOwn
888889
}
889890
return canonicalOwnerRefPayload(normalized), nil
890891
case "":
891-
// Fall through to the direct owner path below so empty ownerRef does
892-
// not create a distinct fingerprint representation.
892+
return nil, fmt.Errorf("ownerRef.type is required")
893893
default:
894894
return nil, fmt.Errorf("ownerRef.type must be owner or external")
895895
}

0 commit comments

Comments
 (0)