chore(api): harden EF model constraints for Tag, User, Goal, Habit#358
Conversation
Tighten the EF model configuration against the frozen /prod-readiness audit (behavior-preserving): - Tag (UserId, Name) unique index becomes partial (WHERE "IsDeleted" = FALSE), so a tag name can be reused after soft-delete; Name/Color get IsRequired + MaxLength(50). - User ColorScheme(50)/Language(10)/ThemePreference(10)/TimeZone(100) and Goal.Unit(50) gain length bounds on previously unbounded text columns. - Goal.Title(200) and HabitLog.Note(1000) carry advisory max lengths while keeping their encryption converter + text storage (ciphertext is never narrowed). - Tag and Habit gain an explicit User FK with ON DELETE CASCADE, closing a referential-integrity gap (previously no FK existed on these UserId columns). The migration is deploy-safe for the live startup run: it drops/creates the Tag index with IF EXISTS / IF NOT EXISTS guards, deletes rows orphaned by an earlier account-deletion path before validating the new FKs, and runs entirely in one transaction. HabitLog already had a cascading Habit FK via Habit.Logs, so no duplicate was added. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review: PR #358 (orbit-api)
Scope: PR #358 — "chore(api): harden EF model constraints for Tag, User, Goal, Habit" (first review pass — no prior reviews/comments/threads existed on this PR)
Recommendation: REQUEST CHANGES
Summary
This PR narrows several unbounded text columns to varchar(N), adds a partial unique index on Tags(UserId, Name) filtered by IsDeleted = FALSE, and adds two previously-missing Cascade FKs (Habit→User, Tag→User), cleaning up 4 orphaned Habits rows first so the new FKs validate. The migration, OrbitDbContext.cs changes, and new unit tests are well-constructed and well-tested. However, the migration exposes a real, previously-latent gap in request validation on the unauthenticated signup path: Users.Language is narrowed to varchar(10), but two call sites that write it (VerifyCodeCommand, GoogleAuthCommand) have zero length/whitelist validation, so an over-length client-supplied Language value will now throw an unhandled DB exception during account creation instead of failing cleanly (or silently succeeding, as before this PR).
Findings
High
1. Unvalidated Language field on unauthenticated signup endpoints will now throw an unhandled DbUpdateException instead of a validation error
src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs:109andsrc/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs:115calluser.SetLanguage(language), wherelanguagecomes directly from the client request (AuthController.cs→VerifyCodeRequest.Language/GoogleAuthRequest.Language, both plain records with no attributes, default"en").User.SetLanguage(src/Orbit.Domain/Entities/User.cs:156) ispublic void SetLanguage(string? language) => Language = language;— no length/format guard.VerifyCodeCommandValidatorandGoogleAuthCommandValidatorhave zero rules forLanguage. The MediatRValidationBehavioronly runs validators registered for that exact command type, so there's no fallback sanitization anywhere in the pipeline.- The codebase already has the correct pattern elsewhere —
SetLanguageCommandValidatoruses.MaximumLength(AppConstants.MaxLanguageLength)plus.Must(lang => AppConstants.SupportedLanguages.Contains(lang))— but it was never applied to the two auth/signup commands. - Before this PR,
Users.Languagewas unboundedtext, so this gap was inert. This migration (HardenModelConstraints) narrows it tocharacter varying(10). ALanguagevalue longer than 10 characters sent toPOST /api/auth/verifyorPOST /api/auth/google(both[AllowAnonymous]) will now causeSaveChangesAsyncto throw duringFindOrCreateUserAsync, breaking new-account creation instead of returning a clean 400. - Fix: add the same
.Must(lang => AppConstants.SupportedLanguages.Contains(lang))rule (or at minimum.MaximumLength(AppConstants.MaxLanguageLength)) to bothVerifyCodeCommandValidatorandGoogleAuthCommandValidator. - Note: inline comments were attempted on the two call sites above, but GitHub rejected them because those lines aren't part of this PR's diff (the gap is pre-existing code newly made reachable by the migration) — flagging here in the body instead.
Medium
2. Orphan cleanup verified only HabitLogs/Tags, not other Cascade-linked join tables on Habit
HardenModelConstraintsmigration deletes orphanedHabits/Tagsrows whoseUserIdmatches noUser, so the new FK constraints validate. The PR description states verification covered onlyHabitLogscount (0) andorphan_tags(0).ChallengeParticipantHabit.HabitIdandAccountabilityPairHabit.HabitIdare also configured withOnDelete(DeleteBehavior.Cascade)againstHabit. If any of the 4 orphaned habits are linked to a challenge or accountability pairing, the rawDELETE FROM "Habits"will cascade-remove those join rows in production with no verification — a data-loss path outside what was checked, andDown()does not restore deleted rows.- Fix: before merging/running against production, verify (and document) a
0count againstChallengeParticipantHabitandAccountabilityPairHabitfor the 4 orphaned habit ids, consistent with the diligence already applied toHabitLogs.
What's good
- The migration correctly orders operations (drop old index → narrow columns → recreate filtered index → delete orphans → add FKs) so nothing validates against soon-to-be-deleted bad data.
Down()is a complete, symmetric reversal ofUp(), including restoring the unfiltered unique index.- New tests (
Model_TagUniqueIndex_FiltersSoftDeletedRowsSoNamesCanBeReusedAfterDeletion,Model_UnboundedStringColumns_AreConstrainedToMaxLengths,Model_EncryptedTextColumns_CarryAdvisoryMaxLengthWithoutNarrowingStorageType,Model_HabitsAndTags_CascadeDeleteWithTheirOwningUser) directly assert the new model behavior. - Cross-checked the other narrowed fields (
Tag.Name,Tag.Color,Goal.Unit,User.TimeZone) against their FluentValidation validators — all are already guarded with matching or stricterMaximumLength/regex rules, so only theLanguagepath above is exposed.User.ColorScheme/ThemePreferencehave no explicitMaximumLengthvalidator but are safe in practice today via closed domain-layer whitelists. - The advisory
HasMaxLengthon the two encryptedtextcolumns (Goal.Title,HabitLog.Note) is correctly scoped to avoid generating DDL at design time, matching the PR's stated intent — confirmed no encrypted-at-rest column had its storage type narrowed. - No SQL injection risk in the migration's raw
Sql()calls — both DELETE statements are fully static, no interpolation of external input.
Recommendation
Fix the High finding — add a Language length/whitelist rule to VerifyCodeCommandValidator and GoogleAuthCommandValidator — before merging, since it's reachable from an unauthenticated endpoint and this migration is what turns a previously-latent gap into a live 500-on-signup risk. Close out the Medium orphan-cascade verification (or explicitly document it as already-checked-and-safe) before this migration runs against production.
…p safe Address PR #358 review (High finding): narrowing Users.Language to varchar(10) turned a previously-inert gap into a live 500-on-signup risk, because the [AllowAnonymous] /api/auth/verify and /api/auth/google paths write a client-supplied Language with no length validator. Language is the only narrowed column not already guarded by a validator, so its bound is dropped here (kept as text). Bounding Language belongs with input guards on VerifyCodeCommandValidator/GoogleAuthCommandValidator, which are outside this slice's owned files. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — both findings addressed: High (Language 500-on-signup): Confirmed real. Medium (orphan cascade verification): Verified read-only against production for the 4 orphaned habit ids across every Cascade/SetNull dependent: |
There was a problem hiding this comment.
PR Review — #358 "chore(api): harden EF model constraints for Tag, User, Goal, Habit"
Recommendation: APPROVE
This is the second review pass. Checked prior reviews/threads first: the earlier review (CHANGES_REQUESTED) raised one High and one Medium finding. Both are now resolved and nothing new surfaced since.
What changed since the last review
Commit b4f8f78f3b3b3818ac594e59b15f5037cc651ae5 ("fix(api): drop Users.Language narrowing to keep unauthenticated signup safe") is a clean, minimal revert across exactly the 5 files that needed it:
src/Orbit.Infrastructure/Migrations/20260712221154_HardenModelConstraints.cs(bothUp()/Down()— theLanguageAlterColumnblocks removed)src/Orbit.Infrastructure/Migrations/20260712221154_HardenModelConstraints.Designer.cssrc/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cssrc/Orbit.Infrastructure/Persistence/OrbitDbContext.cs:688(onlyColorScheme/ThemePreference/TimeZoneretainHasMaxLengthnow)tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs(matching assertion removed)
Verified via full-diff grep that Language no longer carries any HasMaxLength/narrowed-column DDL anywhere in the generated or hand-written artifacts — it stays plain text, closing the High finding (unauthenticated /api/auth/verify and /api/auth/google writing an unvalidated client-supplied Language would otherwise have thrown DbUpdateException on signup once narrowed to varchar(10)).
The Medium finding (orphan-cascade verification scope) is addressed via the author's PR comment documenting a production read-only check across every Cascade/SetNull-dependent table on Habit (ChallengeParticipantHabits, AccountabilityPairHabits, HabitTags, HabitGoals, Cheers all = 0, plus the original HabitLogs = 0).
Security review
- No SQL injection risk (migration's
Sql()calls are all static strings). - New
CascadeFKs (Habit→User,Tag→User) don't conflict withAccountDeletionService, which already explicitly deletes Habits/Tags beforeUsers.Remove— no double-delete/race. - Remaining narrowed columns (
Tag.Name,Tag.Color,Goal.Unit,User.TimeZone,User.ColorScheme,User.ThemePreference) are all guarded by existingMaximumLength/domain-whitelist validators on authenticated-only endpoints. - No controller files in this diff.
Deferred / N/A
Cross-repo contract check N/A — no DTO/endpoint touched in this diff, so nothing to check against the un-checked-out orbit-ui-mobile sibling repo. Parity/i18n/DESIGN.md/FEATURES.md dimensions N/A — no frontend surface, no user-facing feature-surface change.
Minor, non-blocking note
The PR body's "Changes"/"Tests" sections still reference Language(10) narrowing and "seven new max-lengths" — stale since the fix commit (now six: Tag.Name, Tag.Color, User.ColorScheme, User.ThemePreference, User.TimeZone, Goal.Unit). Cosmetic only, not counted as a finding.
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 0 |
| Medium | 0 |
| Low / Info | 0 posted (1 cosmetic note, non-blocking) |
Validation: Build/Tests skipped per CI adaptation — handled by separate required Build / Unit Tests / SonarCloud checks.
…ed signup safe" This reverts commit b4f8f78.
Restores the Users.Language varchar(10) narrowing (reverted in b4f8f78) and adds the missing FluentValidation guard to VerifyCodeCommandValidator and GoogleAuthCommandValidator so an over-length or unsupported Language on the unauthenticated /api/auth/verify and /api/auth/google endpoints returns a clean 400 instead of a DbUpdateException. Matches the existing SupportedLanguages/MaxLanguageLength pattern. Prod verified: no existing Language value exceeds 10 chars, so the narrowing is deploy-safe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed both review findings (HEAD now High — Language signup-500 risk (complete fix, not a scope-down). Rather than dropping the Medium — orphan cascade. Verified against prod: the 4 orphaned |
|
There was a problem hiding this comment.
Code Review: PR #358 — chore(api): harden EF model constraints for Tag, User, Goal, Habit
Scope: Third review pass. Prior reviews checked: first pass (CHANGES_REQUESTED, High: unvalidated Language on signup path; Medium: orphan-cascade verification) — both resolved and approved in the second pass. Since that approval, the author reverted the "drop Language narrowing" workaround and replaced it with the proper fix: re-narrow Users.Language to varchar(10) AND add matching validators on the two auth signup paths. This review focuses on that delta.
Recommendation: APPROVE (no Critical/High findings)
What changed since the last review
Diff scoped to exactly: Users.Language AlterColumn restored in the migration Up()/Down() (+ Designer/snapshot mirrors), plus new .Must(lang => AppConstants.SupportedLanguages.Contains(lang)) rules added to GoogleAuthCommandValidator and VerifyCodeCommandValidator.
This directly and correctly closes the original High finding: instead of leaving Language unbounded text (the earlier workaround), the two unauthenticated signup paths (/api/auth/verify, /api/auth/google) now validate Language before it reaches SaveChangesAsync, matching the existing SetLanguageCommandValidator pattern. An over-length or unsupported Language value now fails cleanly with a 400 instead of risking a DbUpdateException on signup.
Findings
Critical / High
None.
Medium (non-blocking, pre-existing — not part of this delta)
Migration Down() recreates the Tags unique index as non-partial, contradicting the PR's rollback-safety claim.
src/Orbit.Infrastructure/Migrations/20260712221154_HardenModelConstraints.cs — Up() replaces IX_Tags_UserId_Name with a partial index (WHERE "IsDeleted" = FALSE) so a tag name can be reused after soft-delete. Down() recreates it non-partial. Once a user has soft-deleted and recreated a tag with the same name (the exact scenario this PR enables), an emergency Down() will fail with a unique-violation. This code is unchanged since the first commit and was already implicitly approved in the prior review pass — not gating this decision, but worth a fast follow-up: add WHERE "IsDeleted" = FALSE to the Down() index recreation, or document the migration as forward-only once the feature is exercised.
Low / Info
None new.
Security review
Subagent (security-reviewer) reviewed the delta (GoogleAuthCommandValidator.cs, VerifyCodeCommandValidator.cs, migration) — PASS, no findings. Whitelist check is a bounded in-memory Contains against a fixed set, no injection/DoS surface. Migration Sql() calls remain fully static.
Deferred / N/A
- Cross-repo contract check — N/A, no DTO/endpoint/Controller touched.
- Timezone / Authorization hard rules — N/A, not implicated by this diff.
Recommendation
Approve. The delta correctly resolves the previously-flagged High finding with the proper fix (validation, not narrowing removal). The one Medium note is pre-existing, non-blocking, and doesn't affect the automated forward-deploy path.



Behavior-preserving EF model hardening against the frozen
/prod-readinessaudit (code-quality Batch 5). OwnsOrbitDbContext.cs+ one new migration.Changes
(UserId, Name)unique index → partial (WHERE "IsDeleted" = FALSE) so a tag name can be reused after soft-delete;Name/ColorgetIsRequired().HasMaxLength(50).ColorScheme(50),Language(10),ThemePreference(10),TimeZone(100)and GoalUnit(50)— length bounds on previously unboundedtextcolumns.textstorage (ciphertext is never narrowed; these produce no DDL because the design-time model has no encryption).HasOne<User>().WithMany().HasForeignKey(UserId).OnDelete(Cascade)— closing a referential-integrity gap.Deviations from the task premise (evidence-backed)
Habit→User/Tag→User— the model snapshot had no relationship block, soUserIdwas a plain scalar. Adding the FK is therefore realADD CONSTRAINTDDL, not a no-op.Habit.Logs(Cascade,Required). Adding the requested second explicit FK would have created a duplicate/shadow FK, so it was not added — the audit finding is already satisfied.Deploy safety (live startup migration — ref #345/#338 42P07 incident)
Verified against production (Supabase) before constraining:
Languagemax 5,TimeZonemax 19,Tag.Namemax 11) — noALTER COLUMNnarrowing can fail.Habitsreference two hard-deleted users (0 logs each) — a validating FK add would have crashed the deploy. The migration deletes rows whoseUserIdreferences noUserbefore validating the FKs (root-cause cleanup of data an old account-deletion path left behind;orphan_tagswas 0 but the same guard is applied symmetrically).DROP INDEX IF EXISTS/CREATE UNIQUE INDEX IF NOT EXISTS. No prior migration created these FKs or a filteredIX_Tags_UserId_Name.Down()cleanly reverses.Tests
Extended
OrbitDbContextTestswith model assertions for the partial-index filter, the seven new max-lengths (incl. encrypted columns stayingtext), and the two cascading User FKs. Full suite green (4943 tests, 0 failures);dotnet build+dotnet ef migrations scriptverified.Refs thomasluizon/orbit-ui-mobile#243