fix(store): scope SetDomainStatus by project (IDOR); scheduler reuses DeriveStatus
handleCheck's error branch wrote last_check_status via an id-only UPDATE, so an authenticated caller's own valid project id paired with a foreign domain id in the URL could flip a stranger's domain to "error" even though Check itself is project-scoped and would 404/error out first. Add project_id to the WHERE clause (queries/domains.sql + generated db/domains.sql.go), thread projectID through Store/TenantStore/SchedStore SetDomainStatus, and pass pid from context at both call sites in handleCheck plus the scheduler. Also collapse checkDomain's inline status derivation in scheduler.go into a call to service.DeriveStatus, the same helper handleCheck already uses, so there's a single source of truth for "drift vs in_sync" instead of two copies that could drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -172,8 +172,9 @@ func TestCheckEndpoint_PersistsDriftStatus(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].status != service.StatusDrift {
|
||||
t.Fatalf("expected SetDomainStatus(%s, drift), got %+v", did, ts.statusCalls)
|
||||
wantPID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].projectID != wantPID || ts.statusCalls[0].status != service.StatusDrift {
|
||||
t.Fatalf("expected SetDomainStatus(%s, %s, drift), got %+v", did, wantPID, ts.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,8 +195,9 @@ func TestCheckEndpoint_PersistsInSyncStatus(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].status != service.StatusInSync {
|
||||
t.Fatalf("expected SetDomainStatus(_, in_sync), got %+v", ts.statusCalls)
|
||||
wantPID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].projectID != wantPID || ts.statusCalls[0].status != service.StatusInSync {
|
||||
t.Fatalf("expected SetDomainStatus(_, %s, in_sync), got %+v", wantPID, ts.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +220,51 @@ func TestCheckEndpoint_ErrorPersistsErrorStatus(t *testing.T) {
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].status != service.StatusError {
|
||||
t.Fatalf("expected SetDomainStatus(%s, error), got %+v", did, ts.statusCalls)
|
||||
wantPID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||
if len(ts.statusCalls) != 1 || ts.statusCalls[0].domainID != did || ts.statusCalls[0].projectID != wantPID || ts.statusCalls[0].status != service.StatusError {
|
||||
t.Fatalf("expected SetDomainStatus(%s, %s, error), got %+v", did, wantPID, ts.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckEndpoint_ErrorScopesStatusToCallerProject covers the HIGH
|
||||
// IDOR-on-write fix: handleCheck's error branch must persist the failure
|
||||
// status scoped to the caller's OWN project (pid from the URL/context), even
|
||||
// when the domain ID in the URL belongs to (or doesn't exist in) a different
|
||||
// tenant. The handler itself has no way to know whether did is foreign — the
|
||||
// scoping guarantee comes from always passing pid through to
|
||||
// Store.SetDomainStatus, which is enforced to be a no-op for a mismatched
|
||||
// project_id at the store/SQL layer (see internal/store/schedule_test.go's
|
||||
// TestSetDomainStatus_ScopedByProject_ForeignProjectIsNoOp). This test proves
|
||||
// the handler holds up its side of that contract: pid, never a zero value or
|
||||
// some other project, is what gets passed down.
|
||||
func TestCheckEndpoint_ErrorScopesStatusToCallerProject(t *testing.T) {
|
||||
a, m := newTestAPI()
|
||||
ts := a.Store.(*mockTenantStore)
|
||||
m.checkErr = errors.New("boom: provider unreachable")
|
||||
router := NewRouter(a)
|
||||
|
||||
callerPID := uuid.New()
|
||||
// foreignDID stands in for a domain ID the caller does not own — from the
|
||||
// handler's perspective it's just whatever {did} was in the URL; only the
|
||||
// store layer can (and does) enforce that it isn't actually foreignPID's.
|
||||
foreignDID := uuid.New()
|
||||
req := requestWithSessionCookie(http.MethodGet,
|
||||
"/api/v1/projects/"+callerPID.String()+"/domains/"+foreignDID.String()+"/check", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(ts.statusCalls) != 1 {
|
||||
t.Fatalf("expected exactly 1 SetDomainStatus call, got %+v", ts.statusCalls)
|
||||
}
|
||||
call := ts.statusCalls[0]
|
||||
if call.projectID != callerPID {
|
||||
t.Fatalf("expected SetDomainStatus scoped to caller's own pid %s, got projectID %s (never empty/foreign)", callerPID, call.projectID)
|
||||
}
|
||||
if call.domainID != foreignDID || call.status != service.StatusError {
|
||||
t.Fatalf("expected SetDomainStatus(%s, %s, error), got %+v", foreignDID, callerPID, call)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user