cubeops: propagate database errors from the settings and user getters - #1382
cubeops: propagate database errors from the settings and user getters#1382dwin-gharibi wants to merge 6 commits into
Conversation
… the settings and user getters
…proper tests for auth db errors testing
| if errors.Is(err, sql.ErrNoRows) || val == "" { | ||
| return "", nil | ||
| if err != nil { | ||
| if errors.Is(err, sql.ErrNoRows) { |
There was a problem hiding this comment.
This sql.ErrNoRows branch can't fire with the current GORM setup, so the "absent" case is actually detected by the fall-through below.
db.Raw(...).Scan(&val) goes through GORM's Scan (not *sql.Row), and GORM returns a nil error when the result set is empty — with the default &gorm.Config{} (no TranslateError) used in CubeDB/dao/driver/mysql/mysql.go:61, it never surfaces sql.ErrNoRows. That's why the codebase's other getters that genuinely need to detect a missing row use .Row().Scan(...) instead (refresh_token.go:33-35, agenthub.go:229-231). The old code's val == "" clause was the effective check, and here the err == nil path returning the zero value (return val, nil) is what keeps "absent → ("", nil)" working.
This is harmless (behavior is correct) and even a forward-compatible safety net if GORM ever changes, so no change is strictly required — but the PR description presents this branch as the mechanism that separates "absent" from "error", which isn't what happens today. A one-line comment noting the empty-value fall-through is the real path would prevent a future reader from "fixing" it. Same applies to the identical branches in GetSetting and GetUserPassword.
| } | ||
|
|
||
| func TestChangePasswordSurfacesInfrastructureError(t *testing.T) { | ||
| dbDown := errors.New("context canceled") |
There was a problem hiding this comment.
This test is weaker than its Login sibling in two ways:
-
It never asserts the underlying error is preserved.
TestLoginSurfacesInfrastructureErrorcheckserrors.Is(err, dbDown), but here onlyerr != nil && !errors.Is(err, ErrInvalidOldPassword)is asserted. A regression to a different non-sentinel error that discards the cause (e.g.errors.New("read failed")) would still pass. Consider addingif !errors.Is(err, dbDown) { t.Fatalf(...) }for parity. -
errors.New("context canceled")is a misleading stand-in for the "database unreachable" case — a context cancellation is a different failure mode and could mask a wrong reason. The Login test's"dial tcp ...: connection refused"is a better fit.
Review: cubeops: propagate database errors from the settings and user getters (#1382)Automated review — AI-generated, not a human approval. VerdictThe change is sound and fixes a real bug. The three store getters now correctly separate "row absent" ( What I verified
Findings
Minor notes
Intentional behavior change (called out in the PR)
|
…ws really signals an absent row
…-vs-error getter contract
| @@ -80,16 +80,9 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo | |||
| } | |||
| stored, err := s.store.GetUserPassword(ctx, username) | |||
| if err != nil { | |||
There was a problem hiding this comment.
With the store now propagating DB errors, this branch is newly reachable — and internal/auth/handler.go:66-67 writes the wrapped error verbatim into a 500 body (httputil.WriteError(c, http.StatusInternalServerError, err.Error())). During a DB outage an unauthenticated caller now receives {"error":"failed to read user: dial tcp <db-host>:3306: connect: connection refused"}, exposing the internal DB endpoint and driver details. The detail is already logged server-side on the same handler line, so the 500 response should return a generic "internal server error" message instead of echoing err.Error(). The same applies to the ChangePassword 500 path (handler.go:119-123). Since this PR is what makes these paths live, it should close the leak rather than land it as-is.
…dies leak no database detail
| } | ||
| logging.G(c.Request.Context()).Errorf("login failed: internal error: username=%q client_ip=%s error=%q", req.Username, c.ClientIP(), err.Error()) | ||
| httputil.WriteError(c, http.StatusInternalServerError, err.Error()) | ||
| httputil.WriteError(c, http.StatusInternalServerError, "internal server error") |
There was a problem hiding this comment.
Same leak class exists in Refresh below (line 140): this public route (RegisterPublic) still writes err.Error() on its 500 path. svc.Refresh wraps errors from IsRefreshTokenRevoked / CreateRefreshToken, both of which hit the DB, so during an outage the response body exposes driver details (e.g. dial tcp 10.0.0.5:3306: connection refused) to any caller holding a valid refresh token. Not a regression (it predates this PR), but it's the exact leak these two changes eliminate — consider applying the same generic message and covering it with a test like TestLoginOutageDoesNotLeakDatabaseDetailsToTheCaller.
| // This covers the upgrade window where the migration has run | ||
| // (key copied to t_system_setting) but also the case where | ||
| // CubeOps starts against a DB that hasn't been migrated yet. | ||
| // This covers the upgrade window where the migration has run and |
There was a problem hiding this comment.
Minor: this sentence is self-contradictory — if the key were already copied to t_system_setting, step 1 (GetSystemSetting) would have found it and this fallback would never be reached. The window this fallback actually covers is "the migration has run but the key has not yet been copied to t_system_setting" (plus the old-table/rollback case). Suggest rewording so future maintainers don't misread when this path fires.
Closes #1381.
Motivation
GetSystemSetting,GetSettingandGetUserPasswordcollapsed three different outcomes — rowmissing, value empty, and any database error — into
("", nil). Because the scanned value is thezero value whenever the query fails,
val == ""was true for a real error too, so the error wasdiscarded.
The visible symptom is that a database outage is reported as
401 invalid credentialsrather than a500, which is the opposite of whatAuthService.Login's own comment promises. It also made bothbranches of
if err != nilinLoginunreachable dead code.What this changes
CubeOps/internal/store/setting.go— all three getters now separate the two cases:The resulting contract is:
("", nil)means absent; a non-nil error means the read failed. Anempty stored value is still reported as absent, which is what every caller already wanted.
CubeOps/internal/service/auth.go—LoginandChangePasswordupdated for that contract:Loginreturns500for a read failure andErrInvalidCredentialswhen the user is absent(
stored == "") or the password does not match. User enumeration is still not possible: absentand wrong-password are indistinguishable to the caller.
ChangePasswordlikewise returns the wrapped error for a read failure andErrInvalidOldPasswordwhen the user is absent or the old password is wrong.CubeOps/internal/service/auth_test.go— thefakeUserStorereturned("", errors.New("user not found"))for an unknown user, modelling not-found as an error, whilethe real store returns
("", nil). That mismatch is why the tests passed against a contractproduction never implemented. The fake now returns
("", nil); its assertion (unknown user →ErrInvalidCredentials) is unchanged and still passes.Callers that already discard the error (
internal/service/openclaw.go:685-701,internal/service/agenthub.go:457) are untouched — they still get""and still ignore the error, sotheir behaviour is unchanged.
bootstrapMasterKey(internal/store/db.go:83,93) andBootstrapJWTSecret(:139) already hadif err != nilguards that were previously dead; they now actually fire, which is the intendedfail-closed behaviour.
Testing
New:
CubeOps/internal/service/auth_db_error_test.goTestLoginSurfacesInfrastructureError— a DB error is wrapped and returned, and is notErrInvalidCredentials.TestLoginUnknownUserStillReportsInvalidCredentials— no enumeration regression.TestChangePasswordSurfacesInfrastructureError— same for the password-change path.TestChangePasswordUnknownUserReportsBadOldPassword— absent user still reports a bad oldpassword.
CI gates checked locally:
gofmt -l ./internal ./cmd— clean (fmt-check).go build ./...— clean.go test ./...— 0 failures (unit-test-check→make cubeops-test).Risk / rollout
This is a behaviour change on the login path and is the reason it is split out from #1: during a
database outage,
/api/v1/auth/loginnow returns500instead of401. Any monitoring or clientretry logic keyed on
401for that case will see500instead — which is the correct signal, butworth calling out.
The
UserStorecontract change is internal to CubeOps (internal/), so there is no external APIimpact.