Skip to content

[BACK-2780] Add new user profiles endpoint. - #698

Open
lostlevels wants to merge 75 commits into
masterfrom
jimmy/BACK-2780-new-profiles-endpoint
Open

[BACK-2780] Add new user profiles endpoint.#698
lostlevels wants to merge 75 commits into
masterfrom
jimmy/BACK-2780-new-profiles-endpoint

Conversation

@lostlevels

@lostlevels lostlevels commented Feb 12, 2024

Copy link
Copy Markdown
Contributor

Since a lot of this is copy paste from shoreline, for reviewers:

  • shoreline/user/user.go => platform/user/full_user.go - (because there's already a type called User). platform/user.go user.User extended with shoreline user fields.
  • shoreline/user/hasher.go => platform/user/hasher.go
  • shoreline/user/storage.go => platform/user/user_accessor.go (somewhat like the interface of the Storage repository, but simplified).
  • shoreline/user/migrationStore.go => platform/user/keycloak/user_accessor.go (Takes the logic around User creation / manipulation and removes the fallback / mongodb stuff), implements platform/user.UserAccessor interface
  • shoreline/keycloak/client.go => platform/user/keycloak/client.go (Wrapper around gocloak).

@lostlevels
lostlevels force-pushed the jimmy/BACK-2780-new-profiles-endpoint branch from 5abaa66 to 7d32881 Compare March 27, 2024 20:54
Comment thread user/profile.go Outdated
Comment thread user/profile.go Outdated

@toddkazakov toddkazakov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall it looks good. I found few issues:

  • There is a lot of cruft that was copied from shoreline. I believe the majority of those functions are not used. There's no reason to keep unused code in platform, because it only leads to confusion.
  • There is probably a gap in the requirements that I discovered when I read the seagull code to try and figure out why the profile endpoints require custodian permissions. In order to fully deprecate and migrate existing seagull profiles from Mongo to Keycloak, we have to provide an alternative implementation of this functionality. For each user sharing their data with a user with a given id seagull fetches the user object from shoreline and merges it with the user profile attributes stored in the profile object in mongo. The response is sanitized depending on the actual permissions of the requesting user. This deserves its own ticket, but please do some research what the linked code does and then write the requirements. The alternative implementation should be much simpler, because the user account and profiles will be stored in a single service (Keycloak).

Comment thread auth/service/api/v1/profile.go Outdated
Comment thread auth/service/api/v1/profile.go Outdated
Comment thread permission/client/client.go Outdated
Comment thread permission/permission.go Outdated
Comment thread user/full_user.go Outdated
Comment thread user/full_user.go Outdated
Comment thread user/full_user.go Outdated
Comment thread user/hasher.go Outdated
Comment thread user/keycloak/user_accessor.go Outdated
Comment thread user/profile.go Outdated
toddkazakov
toddkazakov previously approved these changes Jun 4, 2024

@toddkazakov toddkazakov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks ok, but needs to be thoroughly tested. @lostlevels please open a new ticket for reimplementing the missing seagull endpoint I mentioned in my previous review.

Comment thread auth/service/api/v1/profile.go Outdated
Comment thread auth/service/api/v1/profile_filter.go Outdated
return slices.Contains(TRUES, value)
}

func parseUsersQuery(query url.Values) *usersProfileFilter {

@toddkazakov toddkazakov Jul 17, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My impression is that this query is not used at all. Can we check blip and uploader?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On checking blip and uploader it seems they are indeed no query string parameters used.

blip
platform-client
uploader
uploader

Can you confirm that no query string parameters are passed for getAssociatedUsersDetails @krystophv @gniezen @jh-bate ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blip and uploader calls are all piped through the platform-client function, so I believe you're correct - no query string params are being sent.

Comment thread auth/service/api/v1/profile.go Outdated
profile = profile.ClearPatientInfo()
} else {
if trustorPerms.HasAny(permission.Custodian, permission.Read, permission.Write) {
// TODO: need to read seagull.value.settings - confirm this is actually used

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings are not part of the profile object so probably there's no need to do anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the settings and preferences returned in the old seagull code, but don't know if they are actually used by clients. @krystophv @clintonium-119 @gniezen Do you know if the returned result of paltform-client getAssociatedUsersDetails ever uses the returned users' settings or preferences in blip or uploader?

Comment thread auth/service/api/v1/profile.go Outdated
// TODO: need to read seagull.value.settings - confirm this is actually used
}
if trustorPerms.Has(permission.Custodian) {
// TODO: need to read seagull.value.preferences - confirm this is actually used

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preferences are not part of the profile object so probably there's no need to do anything?

@lostlevels

Copy link
Copy Markdown
Contributor Author

/deploy qa1 auth

@tidebot

tidebot commented Jun 24, 2025

Copy link
Copy Markdown
Collaborator

lostlevels updated values.yaml file in qa1

@tidebot

tidebot commented Jun 24, 2025

Copy link
Copy Markdown
Collaborator

lostlevels updated flux policies file in qa1

@tidebot

tidebot commented Jun 24, 2025

Copy link
Copy Markdown
Collaborator

lostlevels deployed platform jimmy/BACK-2780-new-profiles-endpoint branch to qa1 namespace

@lostlevels
lostlevels force-pushed the jimmy/BACK-2780-new-profiles-endpoint branch from 6d1b1c7 to 7d53e78 Compare August 13, 2025 20:40
@lostlevels
lostlevels force-pushed the jimmy/BACK-2780-new-profiles-endpoint branch from 8337700 to 6fd7649 Compare April 14, 2026 09:10
@lostlevels
lostlevels force-pushed the jimmy/BACK-2780-new-profiles-endpoint branch from 6fd7649 to 97daee8 Compare April 20, 2026 14:15

@toddkazakov toddkazakov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The biggest issue are the looser permissions in /v1/users/:userId/users and the lack of sanitization in /v1/users/:userId/profile and /v1/users/legacy/:userId/profile. In addition to that, I think the code can be further cleaned up.

Comment thread user/fallback_user_accessor.go
Comment thread user/keycloak/client.go Outdated
Comment thread user/keycloak/client.go Outdated
Comment thread user/keycloak/client.go Outdated
Comment thread user/user.go Outdated
Comment thread user/profile.go Outdated
Comment thread store/structured/mongo/config.go Outdated
Comment thread permission/permission.go Outdated
Comment thread permission/permission.go Outdated
Comment thread permission/permission.go Outdated
@lostlevels
lostlevels requested a review from toddkazakov June 17, 2026 16:30

@toddkazakov toddkazakov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a new bug when merging the existing user attributes with the updated profile attributes, which makes it impossible to remove an attribute from the user profile.

I feel that the code should be cleaned up further - there's a lot of unused cruft that has been copied over from shoreline.

Comment thread user/keycloak/user_accessor.go Outdated
Comment thread user/user.go Outdated
Comment thread user/user.go Outdated
Comment thread user/user.go
Comment thread user/user.go Outdated
Comment thread user/user.go Outdated
Comment thread user/keycloak/client.go
}

attrs := map[string][]string{}
maps.Copy(attrs, user.Attributes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this was added as a result of my previous review where I flagged an issue that removes non-profile attributes from the keycloak user, but this introduces a new bug. Profile fields cannot be removed from the user profile because only non-empty attributes are added to the map in user.Profile.ToAttributes().

Comment thread user/timeutil.go
@@ -0,0 +1,30 @@
package user

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this file be moved to the keycloak package?

Comment thread user/profile.go
}
if up.Custodian != nil && up.Custodian.FullName != "" {
addAttribute(attributes, "custodian_full_name", up.Custodian.FullName)
// The "has_custodian" attribute is only added so that filtering on users is simpler via the keycloak API - because

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using a boolean attribute can we just add the custodial role to the account instead?

@@ -0,0 +1,114 @@
package mongo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file seems misplaced - should it be in the user package?

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added profile retrieval and update APIs, including support for legacy profile formats.
    • Added profile access controls for self-access, trusted users, custodians, and service clients.
    • Added profile aggregation for trusted users with permission-aware data visibility.
    • Added Keycloak-backed user and profile management.
    • Added compatibility support for migrating legacy Seagull profiles.
    • Added permission checks for sharing relationships and custodian access.
    • Added profile validation, sanitization, normalization, and timestamp utilities.
  • Bug Fixes
    • Unified user ID validation across services.

Walkthrough

The change adds profile models and migration handling across Keycloak and Seagull, permission-based authorization, profile API routes, service wiring, and generated test mocks.

Changes

Profile migration and authorization

Layer / File(s) Summary
Profile and user contracts
user/*, auth/user.go
Added profile models, legacy conversion, validation, sanitization, migration states, accessor interfaces, and timestamp utilities.
Keycloak and legacy profile access
user/keycloak/*, user/fallback_user_accessor.go, auth/store/mongo/*, store/structured/mongo/config.go, env*.sh, go.mod
Added Keycloak access, Seagull profile persistence, fallback routing, prefixed Mongo configuration, and runtime configuration.
Permission queries and service wiring
permission/*, auth/service/service*, auth/service/test/service.go, auth/test/*, data/service/api/v1/mocks/mocks.go
Added permission queries and relationship checks. Extended service accessors and test implementations.
Profile authorization and routes
auth/service/api/v1/profile.go, auth/service/api/v1/permission.go, auth/service/api/v1/router.go, auth/service/api/v1/router_test.go
Added profile retrieval, updates, trusted-user aggregation, sanitization, authorization middleware, route registration, and HTTP coverage.
Mocks and test support
appvalidate/*, data/client/test/*, data/service/api/v1/mocks/*, prescription/application/test/*, user/user_mock.go, */test/*_mocks.go
Added generated GoMock implementations and reordered generated imports. Updated test fixtures and matchers.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProfileRouter
  participant PermissionClient
  participant FallbackLegacyUserAccessor
  participant Keycloak
  participant Seagull
  Client->>ProfileRouter: Request profile
  ProfileRouter->>PermissionClient: Check sharing or custodian access
  PermissionClient-->>ProfileRouter: Permission result
  ProfileRouter->>FallbackLegacyUserAccessor: Find or update profile
  FallbackLegacyUserAccessor->>Seagull: Read legacy profile
  Seagull-->>FallbackLegacyUserAccessor: Profile and migration status
  FallbackLegacyUserAccessor->>Keycloak: Read or update migrated profile
  Keycloak-->>FallbackLegacyUserAccessor: Profile result
  FallbackLegacyUserAccessor-->>ProfileRouter: Profile result
  ProfileRouter-->>Client: HTTP response
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding a new user profiles endpoint.
Description check ✅ Passed The description relates the changes to migrating user and Keycloak functionality into the platform user package.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch jimmy/BACK-2780-new-profiles-endpoint
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jimmy/BACK-2780-new-profiles-endpoint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (8)
user/test/user.go-74-81 (1)

74-81: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert Profile in user test helpers.

RandomUser and NewObjectFromUser omit Profile, and MatchUser ignores it. Tests using these helpers can pass when profile data is lost during parsing or client reads. Add profile fixtures, serialization, and an explicit Profile matcher.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/test/user.go` around lines 74 - 81, Update the RandomUser and
NewObjectFromUser helpers to populate and serialize the Profile field, then
extend MatchUser with an explicit Profile matcher alongside the existing fields.
Ensure the fixture, object conversion, and comparison all preserve and validate
profile data.
auth/service/api/v1/profile.go-214-218 (1)

214-218: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Return the persisted profile from both update handlers.

UpdateUser retains existing Keycloak attributes and overlays only non-empty request fields. Omitted fields remain stored, but both handlers return the request profile. Reload the profile before responding or return the persisted value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/service/api/v1/profile.go` around lines 214 - 218, Update both profile
update handlers, including the one calling UpdateLegacyUserProfile and its
counterpart, to respond with the persisted profile rather than the request
object. Reload the profile after a successful update, or use the update
operation’s persisted return value, and pass that value to responder.Data while
preserving existing error handling.
user/profile.go-414-421 (1)

414-421: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the addAttributes return value and drop the unused helper.

addAttributes assigns ok in the loop and then returns the literal true, so the result never reflects whether anything was added. golangci-lint reports the ineffectual assignment at line 417. golangci-lint also reports containsAnyAttributeKeys as unused at line 432.

🐛 Proposed fix
 func addAttributes(attributes map[string][]string, attribute string, values ...string) (ok bool) {
 	for _, value := range values {
 		if addAttribute(attributes, attribute, value) {
 			ok = true
 		}
 	}
-	return true
+	return ok
 }

Remove containsAnyAttributeKeys if no caller exists:

#!/bin/bash
rg -n '\bcontainsAnyAttributeKeys\s*\(' --type=go

Also applies to: 432-439

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 414 - 421, Update addAttributes to return the
accumulated ok value after processing all values, preserving false when no call
to addAttribute succeeds. Remove the unused containsAnyAttributeKeys helper if
no callers exist.

Source: Linters/SAST tools

user/timeutil.go-22-30 (1)

22-30: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Format in UTC to make the output deterministic.

time.Unix(i, 0) returns a time.Time in the process local zone. The formatted offset therefore depends on the container TZ setting, so the same input yields different stored strings across environments.

🐛 Proposed fix
-	t := time.Unix(i, 0)
+	t := time.Unix(i, 0).UTC()
 	timestamp = t.Format(TimestampFormat)

Also prefer strconv.FormatInt(parsed.Unix(), 10) over fmt.Sprintf("%v", ...) at line 18, and use explicit returns instead of naked returns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/timeutil.go` around lines 22 - 30, Update UnixStringToTimestamp to
format the time in UTC so identical Unix inputs produce deterministic output
regardless of the process time zone; replace naked returns with explicit return
values, and in the related timestamp conversion use
strconv.FormatInt(parsed.Unix(), 10) instead of fmt.Sprintf.
user/user_accessor.go-58-65 (1)

58-65: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Change the ExpiresAt JSON tag to exp. The current Keycloak path sets this field from decoded claims, so the typo does not affect the current flow. It still breaks JSON serialization and deserialization of this standard claim.

Suggested change
-	ExpiresAt        int64       `json:"eat"`
+	ExpiresAt        int64       `json:"exp"`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/user_accessor.go` around lines 58 - 65, Update the ExpiresAt field in
TokenIntrospectionResult to use the standard JSON tag exp instead of eat,
preserving the existing field type and behavior.
user/profile.go-441-447 (1)

441-447: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report date errors on the scoped reference.

v.String("date", ...) produces /birthday/date, but the JSON field is /birthday. Do not use v.String("", ...); it produces /birthday/. Parse the value directly and call v.ReportError(...) with structureValidator.ErrorValueStringAsTimeNotValid(...) so the error stays on /birthday.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 441 - 447, Update Date.Validate to parse the
non-empty date value directly instead of calling v.String("date", ...), then
report parsing failures with v.ReportError using
structureValidator.ErrorValueStringAsTimeNotValid(...). Keep the error reference
scoped to the Date field itself, avoiding both the "date" child path and an
empty-name trailing slash.
env.sh-77-83 (1)

77-83: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The new SEAGULL_TIDEPOOL_STORE_* configuration is inconsistent across the environment scripts. Config.LoadPrefix("SEAGULL") introduces a second Mongo configuration set, but the two scripts define it differently: env.sh sets credentials that the local Mongo instance probably does not require, and env.test.sh omits the block entirely.

  • env.sh#L77-L83: remove SEAGULL_TIDEPOOL_STORE_USERNAME, SEAGULL_TIDEPOOL_STORE_PASSWORD, and the authSource=admin option so the Seagull store matches the unauthenticated local Mongo setup at lines 3-5, or document the required local Mongo user.
  • env.test.sh#L20-L31: add the SEAGULL_TIDEPOOL_STORE_* block with test values (for example SEAGULL_TIDEPOOL_STORE_DATABASE="seagull_test") so tests that build the legacy Seagull repository can load their configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@env.sh` around lines 77 - 83, The Seagull Tidepool store environment
configuration is inconsistent between scripts. In env.sh lines 77-83, remove the
username, password, and authSource settings so it matches the unauthenticated
local Mongo configuration; in env.test.sh lines 20-31, add the complete
SEAGULL_TIDEPOOL_STORE_* block with test values, including a test database such
as seagull_test.
user/keycloak/client.go-242-280 (1)

242-280: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard customClaims.ExpiresAt before calling Unix(). jwt.RegisteredClaims.ExpiresAt is a pointer, and exp is optional during decoding. A token without exp leaves this field nil and can panic this request path. Return an error or define a value for missing expiration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/keycloak/client.go` around lines 242 - 280, In IntrospectToken, validate
customClaims.ExpiresAt after DecodeAccessTokenCustomClaims succeeds and before
calling Unix(). Handle a nil expiration explicitly by returning an error or
applying the established missing-expiration value, while preserving the existing
result mapping for tokens with exp.
🧹 Nitpick comments (18)
auth/service/api/v1/profile.go (1)

144-187: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The order of results is nondeterministic.

The goroutines append to results under lock in completion order. Two identical requests can return the same users in a different order. Clients that diff or cache the response see spurious changes, and tests with more than one shared user become order-dependent.

Sort results by user ID before responding.

♻️ Proposed change
 	if err := group.Wait(); err != nil {
 		r.handleUserOrProfileErr(responder, err)
 		return
 	}
 
+	slices.SortFunc(results, func(a, b *user.TrustUser) int {
+		return strings.Compare(pointer.ToString(a.UserID), pointer.ToString(b.UserID))
+	})
+
 	// type TrustUserArray implements Sanitize to hide any properties for non service requests
 	responder.Data(http.StatusOK, results)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/service/api/v1/profile.go` around lines 144 - 187, Sort results by user
ID after group.Wait succeeds and before responder.Data returns the response.
Update the flow around the results accumulation in the errgroup block,
preserving the existing concurrent collection and error handling while ensuring
deterministic ordering for identical requests.
auth/service/api/v1/permission.go (1)

20-47: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Invert the guard and extract the shared authorization logic.

Two concerns in requireCustodian, and requireMembership at Lines 57-84 repeats both.

  1. The body is wrapped in if handlerFunc != nil && res != nil && req != nil. If that condition is false, the middleware writes nothing and returns. go-json-rest then completes the request with an empty 200 OK. An authorization middleware that returns success on a wiring mistake is a fail-open path. The current call sites in profile.go always pass a non-nil method value, so this is not reachable today. Guard against future wiring changes by failing loudly instead.

  2. The two functions are identical except for the permission call and the doc comment. Extract one helper that takes the permission check as a parameter.

Line 24 also creates responder, then Line 28 creates a second responder for the same request. Reuse responder.

♻️ Proposed refactor
+type permissionCheck func(ctx context.Context, granteeUserID, grantorUserID string) (bool, error)
+
+func (r *Router) requireRelationship(targetParamUserID string, check permissionCheck, handlerFunc rest.HandlerFunc) rest.HandlerFunc {
+	return func(res rest.ResponseWriter, req *rest.Request) {
+		if handlerFunc == nil || res == nil || req == nil {
+			panic("auth middleware configured with nil handler, response writer, or request")
+		}
+		targetUserID := req.PathParam(targetParamUserID)
+		responder := request.MustNewResponder(res, req)
+		ctx := req.Context()
+		details := request.GetAuthDetails(ctx)
+		if details == nil {
+			responder.Error(http.StatusUnauthorized, request.ErrorUnauthenticated())
+			return
+		}
+		if details.IsService() || details.UserID() == targetUserID {
+			handlerFunc(res, req)
+			return
+		}
+		hasPerms, err := check(ctx, details.UserID(), targetUserID)
+		if err != nil {
+			responder.InternalServerError(err)
+			return
+		}
+		if !hasPerms {
+			responder.Empty(http.StatusForbidden)
+			return
+		}
+		handlerFunc(res, req)
+	}
+}
+
+func (r *Router) requireCustodian(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc {
+	return r.requireRelationship(targetParamUserID, r.PermissionsClient().HasCustodianPermissions, handlerFunc)
+}
+
+func (r *Router) requireMembership(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc {
+	return r.requireRelationship(targetParamUserID, r.PermissionsClient().UsersHaveSharingRelationship, handlerFunc)
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/service/api/v1/permission.go` around lines 20 - 47, Refactor
requireCustodian and requireMembership to share one authorization middleware
helper that accepts the appropriate permission-check function, preserving their
existing authorization behavior. In the helper, invert the nil guard so invalid
handlerFunc, res, or req inputs fail loudly rather than silently returning a
successful response; reuse the responder created before the authentication check
instead of constructing a second one.
data/service/api/v1/mocks/mocks.go (1)

89-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The four new permission.Client methods were added as fixed-value stubs in every hand-written test double. Each returns nil, nil or false, nil. Tests that route authorization through these doubles always observe "no permissions" and "no relationship", so a test can pass for the wrong reason and no test can drive an error path.

  • data/service/api/v1/mocks/mocks.go#L89-L104: return p.Error and p.Default in the new methods, matching GetUserPermissions at Line 75, and rename the receiver from c to p.
  • auth/test/client.go#L37-L51: record inputs and drain configurable outputs in the four new methods, matching the accessor pattern used elsewhere in the package.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@data/service/api/v1/mocks/mocks.go` around lines 89 - 104, The new
permission.Client methods are fixed-value stubs in two hand-written test
doubles. In data/service/api/v1/mocks/mocks.go lines 89-104, update
PermissionsGrantedToUser, PermissionsGrantedByUser,
UsersHaveSharingRelationship, and HasCustodianPermissions to use receiver p,
returning p.Error and p.Default consistently with GetUserPermissions. In
auth/test/client.go lines 37-51, update the same four methods to record their
inputs and drain configurable outputs following the package’s existing accessor
pattern.
user/user_accessor.go (2)

76-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify with slices.Contains.

The length guard is redundant, and the package already uses slices.Contains in user.go.

♻️ Proposed refactor
 func (t *TokenIntrospectionResult) IsServerToken() bool {
-	if len(t.RealmAccess.Roles) > 0 {
-		for _, role := range t.RealmAccess.Roles {
-			if role == serverRole {
-				return true
-			}
-		}
-	}
-
-	return false
+	return slices.Contains(t.RealmAccess.Roles, serverRole)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/user_accessor.go` around lines 76 - 86, Update
TokenIntrospectionResult.IsServerToken to use slices.Contains directly on
t.RealmAccess.Roles, removing the redundant length guard and manual loop while
preserving the existing boolean result.

18-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the exported role constants in ShorelineManagedRoles.

The map uses raw literals while user.go defines RolePatient, RoleClinic, RoleClinician, and RoleCustodialAccount. The literals can drift from the constants.

♻️ Proposed refactor
-	ShorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}}
+	ShorelineManagedRoles = map[string]struct{}{
+		RolePatient:          {},
+		RoleClinic:           {},
+		RoleClinician:        {},
+		RoleCustodialAccount: {},
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/user_accessor.go` around lines 18 - 19, Update ShorelineManagedRoles to
use the exported constants RolePatient, RoleClinic, RoleClinician, and
RoleCustodialAccount from user.go as its keys instead of raw role-name literals,
preserving the existing managed-role set.
user/profile.go (5)

151-155: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant Clinic assignment.

Line 154 assigns the legacy pointer directly. Lines 180-187 then replace it with a deep clone whenever p.Clinic != nil, and it is nil otherwise. Dropping line 154 removes the momentary aliasing of the legacy struct.

♻️ Proposed refactor
 	up := &Profile{
 		FullName: p.FullName,
-		Clinic:   p.Clinic,
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 151 - 155, Remove the direct Clinic field
assignment from LegacyUserProfile.ToUserProfile. Leave the later deep-clone
handling for p.Clinic != nil and its nil behavior unchanged, so the returned
Profile never temporarily aliases the legacy clinic data.

141-147: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Make the Patient non-nil invariant explicit.

Lines 143 and 146 dereference legacyProfile.Patient. That field is only set inside the IsPatientProfile branch. The code is safe today only because hasPatientFields() returns true when Custodian != nil. If that helper changes, this panics. Allocate the struct locally instead of relying on the remote invariant.

🛡️ Proposed defensive change
 	if up.Custodian != nil {
+		if legacyProfile.Patient == nil {
+			legacyProfile.Patient = &LegacyPatientProfile{}
+		}
 		legacyProfile.Patient.IsOtherPerson = true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 141 - 147, In the Custodian handling block of
the profile conversion function, ensure legacyProfile.Patient is initialized
locally before assigning IsOtherPerson and FullName, rather than relying on
hasPatientFields() or IsPatientProfile to have created it. Preserve the existing
FullName selection behavior.

423-430: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Index the map instead of scanning all keys.

containsAttribute walks every key to find one. addAttribute calls it for each value, so building attributes for a profile scans the map repeatedly.

♻️ Proposed refactor
 func containsAttribute(attributes map[string][]string, attribute, value string) bool {
-	for key, vals := range attributes {
-		if key == attribute && slices.Contains(vals, value) {
-			return true
-		}
-	}
-	return false
+	return slices.Contains(attributes[attribute], value)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 423 - 430, Update containsAttribute to directly
retrieve attributes[attribute] and check whether that value slice contains
value, removing the loop over all map keys while preserving the existing boolean
result for missing attributes.

471-484: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Profile.Normalize skips TargetDevices.

Every other string field is trimmed. TargetDevices entries pass through untrimmed and reach Keycloak attributes through ToAttributes. Trim them for consistency.

♻️ Proposed refactor
 	up.BiologicalSex = strings.TrimSpace(up.BiologicalSex)
+	for i := range up.TargetDevices {
+		up.TargetDevices[i] = strings.TrimSpace(up.TargetDevices[i])
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 471 - 484, Update Profile.Normalize to trim
whitespace from every TargetDevices entry before ToAttributes consumes them,
while preserving the existing normalization of the other profile fields and
nested values.

317-317: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused username parameter.

ProfileFromAttributes never reads username. Removing it prevents callers from assuming the username affects the result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` at line 317, Remove the unused username parameter from
ProfileFromAttributes and update every call site to pass only attributes and
roles, preserving the function’s existing result behavior.
user/legacy_raw_seagull_profile.go (1)

122-131: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Widen dst to match the function name.

The doc comment describes a generic round-trip helper, but the signature only accepts *LegacyUserProfile. Change dst to any (or add a type parameter) so the name matches the behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/legacy_raw_seagull_profile.go` around lines 122 - 131, Update
MarshalThenUnmarshal to accept dst as any instead of *LegacyUserProfile, while
preserving its existing JSON marshal and unmarshal flow so it supports arbitrary
destination types described by the function name.
user/profile_test.go (2)

13-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a malformed existing value.

The spec covers the happy path only. Add a case where seagullValueBefore is not valid JSON, and a case where it is the empty string. Those inputs drive the branch in AddProfileToSeagullValue that discards the existing content.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile_test.go` around lines 13 - 38, Extend the
AddProfileToSeagullValue test context with cases for malformed JSON and an empty
seagullValueBefore string. Verify both inputs discard the existing content and
produce the expected profile value without errors, covering the fallback branch
while preserving the existing happy-path test.

42-110: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extend coverage to the reverse conversion and the attribute mapping.

The table covers ToLegacyProfile only. The migration contract also depends on ToUserProfile, ToAttributes, and ProfileFromAttributes. Add a round-trip case (Profile -> LegacyUserProfile -> Profile and Profile -> attributes -> Profile) and a case for a clinician with no clinic fields, which must produce a non-nil empty Clinic object per the comment in user/profile.go lines 119-126.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile_test.go` around lines 42 - 110, Extend the profile conversion
tests beyond ToLegacyProfile by adding round-trip coverage through ToUserProfile
and through ToAttributes/ProfileFromAttributes, using representative patient
data and asserting the reconstructed profiles. Add a clinician case with no
clinic fields and verify the resulting profile contains a non-nil empty Clinic
object, as required by the Profile conversion contract.
auth/store/mongo/legacy_seagull_profile_repository.go (1)

24-40: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The Mongo store has no shutdown path, and EnsureIndexes does nothing.

NewLegacySeagullProfileRepository creates a store at line 29 and keeps only the repository. Nothing retains the store, so no caller can terminate the client and release its connection pool.

EnsureIndexes returns nil. The userId lookups in this repository need an index, and the conditional-update fix noted above needs a unique index on userId.

Keep the store on the struct and expose a Terminate method. Create the userId index in EnsureIndexes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/store/mongo/legacy_seagull_profile_repository.go` around lines 24 - 40,
Update LegacySeagullProfileRepository to retain the store returned by NewStore,
add a Terminate method that shuts it down, and make EnsureIndexes create the
required unique userId index for lookups and conditional updates.
go.mod (1)

69-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

resty/v2 is marked indirect but the code imports it directly.

user/keycloak/client.go imports github.com/go-resty/resty/v2 at line 14. The module belongs in the direct require block without the // indirect comment. Run go mod tidy to correct the classification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go.mod` at line 69, Move github.com/go-resty/resty/v2 from the indirect
dependency block into the direct require block in go.mod, remove the // indirect
annotation, and run go mod tidy to normalize the module requirements.
user/fallback_user_accessor.go (2)

15-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

roleGetter is never read.

The struct stores roleGetter at line 18 and the constructor sets it at line 25, but no method in this file uses it. Remove the field and the constructor parameter, or use it. Removal changes the constructor signature, which auth/service/service/service.go calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/fallback_user_accessor.go` around lines 15 - 27, Remove the unused
roleGetter field from FallbackLegacyUserAccessor and remove the corresponding
parameter and assignment from NewFallbackLegacyUserAccessor; update the
constructor call in service/service.go to match the reduced signature.

62-75: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The retry loop sleeps after the last attempt and ignores context cancellation.

Two problems exist:

  1. When the third attempt returns ErrUserProfileMigrationInProgress, the loop sleeps for three seconds and then exits. That sleep adds latency to the request thread and changes nothing.
  2. time.Sleep does not observe ctx. When the client disconnects or the request deadline passes, this call still blocks for up to six seconds in total.
♻️ Proposed fix
 	arbritraryRetryLimit := 3
 	var err error
 	for i := range arbritraryRetryLimit {
 		err = f.upsertLegacyUserProfile(ctx, id, profile)
 		if errors.Is(err, ErrUserProfileMigrationInProgress) {
+			if i == arbritraryRetryLimit-1 {
+				break
+			}
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case <-time.After(time.Second * time.Duration(i+1)):
+			}
 			continue
 		}
 		if err != nil {
 			return err
 		}
 		break
 	}
 	return err
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/fallback_user_accessor.go` around lines 62 - 75, Update the retry loop
around upsertLegacyUserProfile so it only waits when another attempt remains,
and replace time.Sleep with a context-aware wait using ctx. Preserve immediate
returns for non-migration errors and return the final migration error after the
retry limit is exhausted, while propagating context cancellation promptly.
user/keycloak/user_accessor.go (1)

54-63: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

UpdateLegacyUserProfile mutates the caller's profile.

Line 60 sets p.Clinic = nil on the pointer that the caller owns. The caller keeps that modified value after the call and may reuse it for a response body or a retry. FallbackLegacyUserAccessor.UpdateLegacyUserProfile retries the same pointer up to three times, so the clinic data is already gone on later attempts.

Copy the profile before you clear the clinic field.

♻️ Proposed refactor
 	if !user.HasClinicOrClinicianRole(roles) && p.Clinic != nil {
-		p.Clinic = nil
+		clone := *p
+		clone.Clinic = nil
+		p = &clone
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/keycloak/user_accessor.go` around lines 54 - 63, Update
UpdateLegacyUserProfile to copy the supplied LegacyUserProfile before applying
the role-based Clinic clearing, and pass the copy to ToUserProfile. Preserve the
original p value so callers and retry logic retain the clinic data.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@auth/service/api/v1/profile.go`:
- Around line 152-165: In the shared-user and profile lookup flow, update the
error handling around UserAccessor().Get and r.getProfile so non-sentinel errors
are propagated before checking nil results. Preserve the user.ErrUserNotFound
and user.ErrUserProfileNotFound cases as successful missing-resource handling,
then apply the nil-result checks only after those errors have been handled.

In `@auth/service/api/v1/router_test.go`:
- Around line 592-598: Update the test fixture sanitizedUserDetails and the
limited user-info test around the sharee cases so sanitization is actually
validated: use a profile with Birthday, DiagnosisDate, and MRN redacted while
preserving the expected non-sensitive fields and permissions. Keep the full
user-info test expecting userDetails, and ensure the limited test compares
against the redacted sanitizedUserDetails.

In `@auth/store/mongo/legacy_seagull_profile_repository.go`:
- Around line 77-113: Make the read-modify-write flow around the repository
method’s initial FindOne/Decode and subsequent FindOneAndUpdate conditional on
the previously read value (and preserve the no-document upsert case safely).
Prevent stale writes from overwriting concurrent migrations or profile updates
by adding the prior value or equivalent migration-status guard to the update
filter, and avoid allowing a non-matching upsert to create duplicates. Treat a
failed conditional update or duplicate-key conflict as a retryable conflict, and
remove reliance on the post-write IsMigrating check to undo changes.

In `@go.mod`:
- Line 72: Update the pinned github.com/golang-jwt/jwt/v5 requirement in go.mod
from v5.0.0 to the first version patched for GO-2025-3553/GHSA-mh63-6h87-95cp,
and retain an explicit version pin. Refresh the corresponding module checksums
or dependency metadata as needed.

In `@permission/client/client.go`:
- Around line 107-131: Update GetUserPermissions and the permission checks in
UsersHaveSharingRelationship and HasCustodianPermissions so an absent access
record is treated as an empty permission set while genuine permission-service
failures still propagate. Ensure reverse-direction checks continue after a
missing forward record and unrelated users reach the existing HTTP 403 path; add
coverage for a reverse-only relationship and the unrelated-user response.

In `@store/structured/mongo/config.go`:
- Around line 74-80: Update Config.LoadPrefix so Seagull-specific loading cannot
fall back to unprefixed TIDEPOOL_STORE_DATABASE when the SEAGULL-prefixed
variable is absent. Use a prefixed-only envconfig loading path or explicitly
validate that the required prefixed database variable is set before returning
success, while preserving the existing Load-to-LoadPrefix delegation.

In `@user/keycloak/client.go`:
- Around line 302-313: Update getAdminToken to acquire the admin-token write
lock before refreshing, re-check adminTokenIsExpired while holding that lock,
and only call loginAsAdmin when the token remains expired; preserve read-locked
access for valid tokens. Add a nil check in loginAsAdmin after jwtToAccessToken,
returning an error for an empty token, and ensure getAdminToken never
dereferences c.adminToken when it is nil.
- Around line 282-295: Update DeleteUserSessions so every non-404 error returned
by LogoutAllSessions is propagated instead of returning the earlier token error
value. Preserve the existing nil-success behavior for APIError responses with
http.StatusNotFound, and return nil only when logout succeeds or the error is
explicitly treated as not found; avoid shadowing the outer err or return the
logout error directly.

In `@user/legacy_raw_seagull_profile.go`:
- Around line 108-120: Update AddProfileToSeagullValue and SetRawValueProfile so
extractSeagullValue errors are returned when the existing value is non-empty,
preserving the original data instead of replacing it with an empty object; only
initialize a new object for an empty value, and reuse AddProfileToSeagullValue
from SetRawValueProfile where appropriate.
- Around line 133-148: Update FallbackLegacyUserAccessor.upsertLegacyUserProfile
to route MigrationUnmigrated, MigrationInProgress, and MigrationError profiles
through the intended migration/retry handling instead of sending only unmigrated
profiles to Seagull. Update LegacySeagullDocument.MigrationStatus to explicitly
handle MigrationEnd being set without MigrationStart, rejecting it or routing it
to an appropriate non-Seagull status so reads and writes do not remain on
Seagull. Do not rely on changing IsMigrating alone.

In `@user/profile.go`:
- Around line 456-469: Update Profile.Validate to apply MaxProfileFieldLen
validation to all Clinic string fields written by ToAttributes: clinic_name,
clinic_role, clinic_telephone, and clinic_npi. Update
LegacyPatientProfile.Validate to add the same length validation for
biologicalSex, matching Profile.Validate and its normalization behavior.

In `@user/timeutil.go`:
- Around line 9-20: Update ParseTimestamp to parse timestamps with time.RFC3339
so valid UTC “Z” timestamps are accepted, while preserving
TimestampToUnixString’s existing conversion behavior. Add coverage for an RFC
3339 input ending in “Z” if tests are available.

In `@user/user.go`:
- Around line 143-151: Update the condition in TrustUser.Sanitize to check
u.UserID for nil before dereferencing it, treating a nil UserID as not the
requesting user while preserving the existing service-user and matching-ID
behavior.
- Around line 40-41: Update custodialAccountRegexp to anchor the pattern at both
the beginning and end of the string, so MatchString only accepts the complete
unclaimed-custodial address and rejects addresses with surrounding or trailing
content.

---

Minor comments:
In `@auth/service/api/v1/profile.go`:
- Around line 214-218: Update both profile update handlers, including the one
calling UpdateLegacyUserProfile and its counterpart, to respond with the
persisted profile rather than the request object. Reload the profile after a
successful update, or use the update operation’s persisted return value, and
pass that value to responder.Data while preserving existing error handling.

In `@env.sh`:
- Around line 77-83: The Seagull Tidepool store environment configuration is
inconsistent between scripts. In env.sh lines 77-83, remove the username,
password, and authSource settings so it matches the unauthenticated local Mongo
configuration; in env.test.sh lines 20-31, add the complete
SEAGULL_TIDEPOOL_STORE_* block with test values, including a test database such
as seagull_test.

In `@user/keycloak/client.go`:
- Around line 242-280: In IntrospectToken, validate customClaims.ExpiresAt after
DecodeAccessTokenCustomClaims succeeds and before calling Unix(). Handle a nil
expiration explicitly by returning an error or applying the established
missing-expiration value, while preserving the existing result mapping for
tokens with exp.

In `@user/profile.go`:
- Around line 414-421: Update addAttributes to return the accumulated ok value
after processing all values, preserving false when no call to addAttribute
succeeds. Remove the unused containsAnyAttributeKeys helper if no callers exist.
- Around line 441-447: Update Date.Validate to parse the non-empty date value
directly instead of calling v.String("date", ...), then report parsing failures
with v.ReportError using structureValidator.ErrorValueStringAsTimeNotValid(...).
Keep the error reference scoped to the Date field itself, avoiding both the
"date" child path and an empty-name trailing slash.

In `@user/test/user.go`:
- Around line 74-81: Update the RandomUser and NewObjectFromUser helpers to
populate and serialize the Profile field, then extend MatchUser with an explicit
Profile matcher alongside the existing fields. Ensure the fixture, object
conversion, and comparison all preserve and validate profile data.

In `@user/timeutil.go`:
- Around line 22-30: Update UnixStringToTimestamp to format the time in UTC so
identical Unix inputs produce deterministic output regardless of the process
time zone; replace naked returns with explicit return values, and in the related
timestamp conversion use strconv.FormatInt(parsed.Unix(), 10) instead of
fmt.Sprintf.

In `@user/user_accessor.go`:
- Around line 58-65: Update the ExpiresAt field in TokenIntrospectionResult to
use the standard JSON tag exp instead of eat, preserving the existing field type
and behavior.

---

Nitpick comments:
In `@auth/service/api/v1/permission.go`:
- Around line 20-47: Refactor requireCustodian and requireMembership to share
one authorization middleware helper that accepts the appropriate
permission-check function, preserving their existing authorization behavior. In
the helper, invert the nil guard so invalid handlerFunc, res, or req inputs fail
loudly rather than silently returning a successful response; reuse the responder
created before the authentication check instead of constructing a second one.

In `@auth/service/api/v1/profile.go`:
- Around line 144-187: Sort results by user ID after group.Wait succeeds and
before responder.Data returns the response. Update the flow around the results
accumulation in the errgroup block, preserving the existing concurrent
collection and error handling while ensuring deterministic ordering for
identical requests.

In `@auth/store/mongo/legacy_seagull_profile_repository.go`:
- Around line 24-40: Update LegacySeagullProfileRepository to retain the store
returned by NewStore, add a Terminate method that shuts it down, and make
EnsureIndexes create the required unique userId index for lookups and
conditional updates.

In `@data/service/api/v1/mocks/mocks.go`:
- Around line 89-104: The new permission.Client methods are fixed-value stubs in
two hand-written test doubles. In data/service/api/v1/mocks/mocks.go lines
89-104, update PermissionsGrantedToUser, PermissionsGrantedByUser,
UsersHaveSharingRelationship, and HasCustodianPermissions to use receiver p,
returning p.Error and p.Default consistently with GetUserPermissions. In
auth/test/client.go lines 37-51, update the same four methods to record their
inputs and drain configurable outputs following the package’s existing accessor
pattern.

In `@go.mod`:
- Line 69: Move github.com/go-resty/resty/v2 from the indirect dependency block
into the direct require block in go.mod, remove the // indirect annotation, and
run go mod tidy to normalize the module requirements.

In `@user/fallback_user_accessor.go`:
- Around line 15-27: Remove the unused roleGetter field from
FallbackLegacyUserAccessor and remove the corresponding parameter and assignment
from NewFallbackLegacyUserAccessor; update the constructor call in
service/service.go to match the reduced signature.
- Around line 62-75: Update the retry loop around upsertLegacyUserProfile so it
only waits when another attempt remains, and replace time.Sleep with a
context-aware wait using ctx. Preserve immediate returns for non-migration
errors and return the final migration error after the retry limit is exhausted,
while propagating context cancellation promptly.

In `@user/keycloak/user_accessor.go`:
- Around line 54-63: Update UpdateLegacyUserProfile to copy the supplied
LegacyUserProfile before applying the role-based Clinic clearing, and pass the
copy to ToUserProfile. Preserve the original p value so callers and retry logic
retain the clinic data.

In `@user/legacy_raw_seagull_profile.go`:
- Around line 122-131: Update MarshalThenUnmarshal to accept dst as any instead
of *LegacyUserProfile, while preserving its existing JSON marshal and unmarshal
flow so it supports arbitrary destination types described by the function name.

In `@user/profile_test.go`:
- Around line 13-38: Extend the AddProfileToSeagullValue test context with cases
for malformed JSON and an empty seagullValueBefore string. Verify both inputs
discard the existing content and produce the expected profile value without
errors, covering the fallback branch while preserving the existing happy-path
test.
- Around line 42-110: Extend the profile conversion tests beyond ToLegacyProfile
by adding round-trip coverage through ToUserProfile and through
ToAttributes/ProfileFromAttributes, using representative patient data and
asserting the reconstructed profiles. Add a clinician case with no clinic fields
and verify the resulting profile contains a non-nil empty Clinic object, as
required by the Profile conversion contract.

In `@user/profile.go`:
- Around line 151-155: Remove the direct Clinic field assignment from
LegacyUserProfile.ToUserProfile. Leave the later deep-clone handling for
p.Clinic != nil and its nil behavior unchanged, so the returned Profile never
temporarily aliases the legacy clinic data.
- Around line 141-147: In the Custodian handling block of the profile conversion
function, ensure legacyProfile.Patient is initialized locally before assigning
IsOtherPerson and FullName, rather than relying on hasPatientFields() or
IsPatientProfile to have created it. Preserve the existing FullName selection
behavior.
- Around line 423-430: Update containsAttribute to directly retrieve
attributes[attribute] and check whether that value slice contains value,
removing the loop over all map keys while preserving the existing boolean result
for missing attributes.
- Around line 471-484: Update Profile.Normalize to trim whitespace from every
TargetDevices entry before ToAttributes consumes them, while preserving the
existing normalization of the other profile fields and nested values.
- Line 317: Remove the unused username parameter from ProfileFromAttributes and
update every call site to pass only attributes and roles, preserving the
function’s existing result behavior.

In `@user/user_accessor.go`:
- Around line 76-86: Update TokenIntrospectionResult.IsServerToken to use
slices.Contains directly on t.RealmAccess.Roles, removing the redundant length
guard and manual loop while preserving the existing boolean result.
- Around line 18-19: Update ShorelineManagedRoles to use the exported constants
RolePatient, RoleClinic, RoleClinician, and RoleCustodialAccount from user.go as
its keys instead of raw role-name literals, preserving the existing managed-role
set.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ce5f8465-6327-4953-9cfd-f19bbdc775af

📥 Commits

Reviewing files that changed from the base of the PR and between 752fbbd and 4437749.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (49)
  • appvalidate/mock.go
  • appvalidate/test/repository_mocks.go
  • auth/service/api/v1/appvalidate_test.go
  • auth/service/api/v1/permission.go
  • auth/service/api/v1/profile.go
  • auth/service/api/v1/router.go
  • auth/service/api/v1/router_test.go
  • auth/service/service.go
  • auth/service/service/service.go
  • auth/service/service/service_test.go
  • auth/service/test/service.go
  • auth/store/mongo/legacy_seagull_profile_repository.go
  • auth/test/auth_mocks.go
  • auth/test/client.go
  • auth/user.go
  • consent/test/service_mocks.go
  • data/client/test/mock.go
  • data/raw/service/test/client_mocks.go
  • data/raw/test/client_mocks.go
  • data/service/api/v1/mocks/mocklogger_test_gen.go
  • data/service/api/v1/mocks/mocks.go
  • data/source/test/data_set_ensurer_mocks.go
  • data/source/test/source_mocks.go
  • dexcom/fetch/test/runner_mocks.go
  • env.sh
  • env.test.sh
  • go.mod
  • permission/client/client.go
  • permission/client_mock.go
  • permission/permission.go
  • prescription/application/test/mock.go
  • store/structured/mongo/config.go
  • task/test/task_mocks.go
  • twiist/provider/test/provider_mocks.go
  • user/fallback_user_accessor.go
  • user/keycloak/client.go
  • user/keycloak/user_accessor.go
  • user/legacy_raw_seagull_profile.go
  • user/profile.go
  • user/profile_test.go
  • user/test/user.go
  • user/test/user_mocks.go
  • user/timeutil.go
  • user/user.go
  • user/user_accessor.go
  • user/user_mock.go
  • work/service/test/client_mocks.go
  • work/test/client_mocks.go
  • work/test/processor_mocks.go

Comment on lines +152 to +165
if stdErrs.Is(err, user.ErrUserNotFound) || sharedUser == nil {
// According to seagull code, "It's possible for a user profile to be deleted before the sharing permissions", so we can ignore if user or profile not found.
return nil
}
if err != nil {
return err
}
seagullProfile, err := r.getProfile(ctx, userID)
if stdErrs.Is(err, user.ErrUserProfileNotFound) || seagullProfile == nil {
return nil
}
if err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect UserAccessor.Get implementations and their error/nil-user contract.
set -euo pipefail

fd -t f 'user_accessor.go|fallback_user_accessor.go' | while IFS= read -r f; do
  echo "===== $f ====="
  cat -n "$f"
done

echo "===== Get implementations across the repo ====="
ast-grep run --lang go --pattern 'func ($_ $_) Get(ctx context.Context, $_ string) (*User, error) { $$$ }'
ast-grep run --lang go --pattern 'func ($_ $_) Get(ctx context.Context, $_ string) (*user.User, error) { $$$ }'

Repository: tidepool-org/platform

Length of output: 14436


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd -t f '^profile\.go$' | grep -E '(^|/)auth/service/api/v1/profile\.go$' | head -n1)"
echo "===== $file outline ====="
ast-grep outline "$file"

echo "===== target section ====="
cat -n "$file" | sed -n '35,180p'

echo "===== getProfile references ====="
rg -n -C 4 'getProfile|UserAccessor\(\)\.Get|sharedUser|seagullProfile' "$file" auth user 2>/dev/null | head -n 500

Repository: tidepool-org/platform

Length of output: 15691


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="auth/service/api/v1/profile.go"
echo "===== handler completion and error mapping ====="
cat -n "$file" | sed -n '175,270p'

echo "===== focused tests ====="
rg -n -C 5 'GetUsersWithProfiles|PermissionsGrantedToUser|PermissionsGrantedByUser|ErrUserProfileNotFound|ErrUserNotFound' \
  auth/service/api/v1 --glob '*_test.go' | head -n 800

echo "===== all errgroup.Wait uses near API handlers ====="
rg -n -C 5 'group\.Wait\(\)|errgroup\.WithContext' auth/service/api/v1 --glob '*.go'

Repository: tidepool-org/platform

Length of output: 11559


Check lookup errors before nil-result checks

getProfile returns nil, err for every lookup failure. The current check treats each failure as a missing profile, so group.Wait succeeds and the handler returns 200 OK with partial results. The same ordering can suppress (nil, err) from UserAccessor().Get. Check sentinel errors, then propagate other errors, before handling nil results.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/service/api/v1/profile.go` around lines 152 - 165, In the shared-user
and profile lookup flow, update the error handling around UserAccessor().Get and
r.getProfile so non-sentinel errors are propagated before checking nil results.
Preserve the user.ErrUserNotFound and user.ErrUserProfileNotFound cases as
successful missing-resource handling, then apply the nil-result checks only
after those errors have been handled.

Comment on lines +592 to +598
sanitizedUserDetails = &user.User{
UserID: pointer.FromString(userID),
Username: pointer.FromString("dev@tidepool.org"),
EmailVerified: pointer.FromBool(true),
Roles: &userRoles,
Profile: &userProfile,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

sanitizedUserDetails is identical to userDetails, so the sanitization tests assert nothing.

Lines 592-598 copy every field from Lines 585-591, including Profile: &userProfile. userProfile carries Birthday, DiagnosisDate, and MRN.

Two tests depend on this value:

  • Line 737, "returns sharer's full user info w/ sharee." expects *sanitizedUserDetails.
  • Line 840, "returns sharer's limited user info w/ sharee." expects the same *sanitizedUserDetails and the same permissions.

Both cases now assert the unsanitized payload, and they assert the identical thing. If TrustUserArray stops redacting patient fields for a session-token caller, neither test fails. The test at Lines 852-872 does check redaction, which shows the two cases above are the gap.

Either build sanitizedUserDetails with a redacted profile, or rename the variable and the test at Line 837 to state that the full profile is expected.

💚 Proposed change
 					sanitizedUserDetails = &user.User{
 						UserID:        pointer.FromString(userID),
 						Username:      pointer.FromString("dev@tidepool.org"),
 						EmailVerified: pointer.FromBool(true),
 						Roles:         &userRoles,
-						Profile:       &userProfile,
+						Profile:       &user.Profile{FullName: "Some User Profile"},
 					}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sanitizedUserDetails = &user.User{
UserID: pointer.FromString(userID),
Username: pointer.FromString("dev@tidepool.org"),
EmailVerified: pointer.FromBool(true),
Roles: &userRoles,
Profile: &userProfile,
}
sanitizedUserDetails = &user.User{
UserID: pointer.FromString(userID),
Username: pointer.FromString("dev@tidepool.org"),
EmailVerified: pointer.FromBool(true),
Roles: &userRoles,
Profile: &user.Profile{FullName: "Some User Profile"},
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/service/api/v1/router_test.go` around lines 592 - 598, Update the test
fixture sanitizedUserDetails and the limited user-info test around the sharee
cases so sanitization is actually validated: use a profile with Birthday,
DiagnosisDate, and MRN redacted while preserving the expected non-sensitive
fields and permissions. Keep the full user-info test expecting userDetails, and
ensure the limited test compares against the redacted sanitizedUserDetails.

Comment on lines +77 to +113
err := p.FindOne(ctx, selector).Decode(&doc)
// A user can have no profile set - see seagull/lib/routes/seagullApi.js `if (err.statusCode == 404 && addIfNotThere)`
if err != nil && !stdErrors.Is(err, mongo.ErrNoDocuments) {
return err
}
hasExistingProfile := err == nil
// We need to make a distinction b/t a seagull profile not existing (in which case we can upsert) versus a seagull profile actively being migrated, which is why we need to actually read the document.
if hasExistingProfile && doc.IsMigrating() {
return user.ErrUserProfileMigrationInProgress
}

// This will create a new value even if doc.Value is empty
updatedValueRaw, err := user.AddProfileToSeagullValue(doc.Value, profile)
if err != nil {
return err
}

uopts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
uselector := bson.M{
"userId": userID,
}
update := bson.M{
"$set": bson.M{
"value": updatedValueRaw,
"userId": userID, // Set because of possible upsert
},
}
var updatedDoc user.LegacySeagullDocument
err = p.FindOneAndUpdate(ctx, uselector, update, uopts).Decode(&updatedDoc)
if err != nil {
return err
}
// Handle case where a migration was started in between the start of this function and the update
if updatedDoc.IsMigrating() {
return user.ErrUserProfileMigrationInProgress
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The read-modify-write is not atomic, so a concurrent migration can be overwritten.

The method reads the document at line 77, merges the profile into doc.Value at line 89, and writes the result at line 105. Between the read and the write, a migration can start or complete. Two consequences follow:

  1. The write clobbers the state that the migrator produced, including the migration status stored in value.
  2. The check at line 110 runs after the write. It returns ErrUserProfileMigrationInProgress, but the document is already modified. The error does not undo the write.

A concurrent update from another request also loses data, because both requests merge into their own stale copy of value.

Make the update conditional so the server rejects a stale write. Include the previously read value (or a migration-status guard) in the filter, and treat "no document matched" as a retryable conflict.

🛡️ Sketch of a conditional update
-	uselector := bson.M{
-		"userId": userID,
-	}
+	uselector := bson.M{
+		"userId": userID,
+	}
+	if hasExistingProfile {
+		// Reject the write if the stored value changed since the read above.
+		uselector["value"] = doc.Value
+	}

With SetUpsert(true) and a non-matching filter, Mongo inserts a duplicate document, so pair this with a unique index on userId, or drop the upsert and handle mongo.ErrNoDocuments as a conflict.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@auth/store/mongo/legacy_seagull_profile_repository.go` around lines 77 - 113,
Make the read-modify-write flow around the repository method’s initial
FindOne/Decode and subsequent FindOneAndUpdate conditional on the previously
read value (and preserve the no-document upsert case safely). Prevent stale
writes from overwriting concurrent migrations or profile updates by adding the
prior value or equivalent migration-status guard to the update filter, and avoid
allowing a non-matching upsert to create duplicates. Treat a failed conditional
update or duplicate-key conflict as a retryable conflict, and remove reliance on
the post-write IsMigrating check to undo changes.

Comment thread go.mod
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang-jwt/jwt/v5 v5.0.0 // indirect

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

golang-jwt/jwt/v5 v5.0.0 has a known high-severity advisory.

OSV reports GO-2025-3553 and GHSA-mh63-6h87-95cp against this version. The flaw allows excessive memory allocation during JWT header parsing. gocloak/v13 pulls this module in, and this service parses Keycloak access tokens on the request path in user/keycloak/client.go.

Bump the module and keep the requirement pinned.

🛡️ Proposed change
-	github.com/golang-jwt/jwt/v5 v5.0.0 // indirect
+	github.com/golang-jwt/jwt/v5 v5.2.2 // indirect

Run the following script to confirm the first patched version:

#!/bin/bash
# Description: Check advisories for github.com/golang-jwt/jwt/v5.
gh api graphql -f query='
{
  securityVulnerabilities(first: 10, ecosystem: GO, package: "github.com/golang-jwt/jwt/v5") {
    nodes {
      advisory { ghsaId summary severity }
      vulnerableVersionRange
      firstPatchedVersion { identifier }
    }
  }
}'
🧰 Tools
🪛 OSV Scanner (2.4.0)

[HIGH] 72-72: github.com/golang-jwt/jwt/v5 5.0.0: Excessive memory allocation during header parsing in github.com/golang-jwt/jwt

(GO-2025-3553)


[HIGH] 72-72: github.com/golang-jwt/jwt/v5 5.0.0: jwt-go allows excessive memory allocation during header parsing

(GHSA-mh63-6h87-95cp)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go.mod` at line 72, Update the pinned github.com/golang-jwt/jwt/v5
requirement in go.mod from v5.0.0 to the first version patched for
GO-2025-3553/GHSA-mh63-6h87-95cp, and retain an explicit version pin. Refresh
the corresponding module checksums or dependency metadata as needed.

Source: Linters/SAST tools

Comment on lines +107 to +131
func (c *Client) UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) {
fromTo, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID)
if err != nil {
return false, err
}
if len(fromTo) > 0 {
return true, nil
}
toFrom, err := c.GetUserPermissions(ctx, grantorUserID, granteeUserID)
if err != nil {
return false, err
}
if len(toFrom) > 0 {
return true, nil
}
return false, nil
}

func (c *Client) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) {
perms, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID)
if err != nil {
return false, err
}
_, ok := perms[permission.Custodian]
return ok, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Treat a missing access record as no permission.

GetUserPermissions converts a resource-not-found response into request.ErrorUnauthorized(). At Line 109, UsersHaveSharingRelationship then exits before it checks the reverse direction. At Line 127, HasCustodianPermissions propagates the same error, and requireCustodian converts it to HTTP 500.

Return an empty permission set for an absent access record, or expose a distinct not-found result. Preserve errors for actual permission-service failures. Add tests for a reverse-only relationship and for an unrelated user receiving HTTP 403.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@permission/client/client.go` around lines 107 - 131, Update
GetUserPermissions and the permission checks in UsersHaveSharingRelationship and
HasCustodianPermissions so an absent access record is treated as an empty
permission set while genuine permission-service failures still propagate. Ensure
reverse-direction checks continue after a missing forward record and unrelated
users reach the existing HTTP 403 path; add coverage for a reverse-only
relationship and the unrelated-user response.

Comment on lines +133 to +148
func (doc *LegacySeagullDocument) MigrationStatus() migrationStatus {
if doc.MigrationStart != nil && doc.MigrationEnd != nil {
return MigrationCompleted
}
if doc.MigrationStart != nil && doc.MigrationEnd == nil && doc.MigrationError == nil {
return MigrationInProgress
}
if doc.MigrationStart != nil && doc.MigrationError != nil {
return MigrationError
}
return MigrationUnmigrated
}

func (doc *LegacySeagullDocument) IsMigrating() bool {
return doc.MigrationStatus() != MigrationUnmigrated
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect every caller of IsMigrating and the fallback routing that consumes migration status.
set -euo pipefail

rg -n -C 6 '\bIsMigrating\s*\(' --type=go
rg -n -C 6 'ErrUserProfileMigrationInProgress|IsMigrationCompleted|MigrationError\b' --type=go

Repository: tidepool-org/platform

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(legacy_seagull_profile_repository|legacy_raw_seagull_profile|user_accessor)\.go$'

printf '%s\n' '--- migration symbols and callers ---'
rg -n -C 8 'IsMigrating|MigrationStatus|MigrationCompleted|MigrationInProgress|MigrationError|ErrUserProfileMigrationInProgress' --glob '*.go' .

printf '%s\n' '--- relevant file outlines ---'
for f in $(git ls-files | rg '(^|/)(legacy_seagull_profile_repository|legacy_raw_seagull_profile|user_accessor)\.go$'); do
  echo "### $f"
  wc -l "$f"
  ast-grep outline "$f" 2>/dev/null | head -120
done

Repository: tidepool-org/platform

Length of output: 27228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- fallback routing and retry behavior ---'
cat -n user/fallback_user_accessor.go | sed -n '1,125p'

printf '%s\n' '--- repository update path ---'
cat -n auth/store/mongo/legacy_seagull_profile_repository.go | sed -n '55,116p'

printf '%s\n' '--- migration marker writers and tests ---'
rg -n -C 8 'Migration(Start|End|Error|ErrorTime)|MigrationStatus\(\)|IsMigrating\(\)' --glob '*.go' .

Repository: tidepool-org/platform

Length of output: 16562


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- repository and fallback wiring ---'
rg -n -C 8 'NewLegacySeagullProfileRepository|LegacySeagullProfileRepository|NewFallbackLegacyUserAccessor|FallbackLegacyUserAccessor' --glob '*.go' .

printf '%s\n' '--- migration-related tests ---'
rg -n -C 10 'Migration(Start|End|Error)|MigrationInProgress|MigrationCompleted|MigrationUnmigrated|MigrationError|ErrUserProfileMigrationInProgress' --glob '*_test.go' .

printf '%s\n' '--- deterministic state/routing probe ---'
python3 - <<'PY'
from itertools import product

states = {
    "unmigrated": (False, False, False),
    "completed": (True, True, False),
    "in_progress": (True, False, False),
    "error": (True, False, True),
    "end_without_start": (False, True, False),
}

def status(start, end, error):
    if start and end:
        return "completed"
    if start and not end and not error:
        return "in_progress"
    if start and error:
        return "error"
    return "unmigrated"

for name, flags in states.items():
    s = status(*flags)
    legacy_repo_blocks = s != "unmigrated"
    fallback_reads_seagull = s != "completed"
    fallback_updates_seagull = s == "unmigrated"
    print(f"{name}: status={s}, IsMigrating={legacy_repo_blocks}, "
          f"fallback_reads_seagull={fallback_reads_seagull}, "
          f"fallback_updates_seagull={fallback_updates_seagull}")
PY

Repository: tidepool-org/platform

Length of output: 19047


Handle migration statuses explicitly in the fallback accessor.

FallbackLegacyUserAccessor.upsertLegacyUserProfile routes MigrationInProgress and MigrationError profiles to Keycloak because it sends only MigrationUnmigrated profiles to Seagull. This bypasses the intended retry path during migration and treats failed migrations as migrated. Changing IsMigrating alone does not fix this path. Also reject or handle MigrationEnd != nil with MigrationStart == nil; MigrationStatus currently classifies it as MigrationUnmigrated and keeps reads and writes on Seagull.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/legacy_raw_seagull_profile.go` around lines 133 - 148, Update
FallbackLegacyUserAccessor.upsertLegacyUserProfile to route MigrationUnmigrated,
MigrationInProgress, and MigrationError profiles through the intended
migration/retry handling instead of sending only unmigrated profiles to Seagull.
Update LegacySeagullDocument.MigrationStatus to explicitly handle MigrationEnd
being set without MigrationStart, rejecting it or routing it to an appropriate
non-Seagull status so reads and writes do not remain on Seagull. Do not rely on
changing IsMigrating alone.

Comment thread user/profile.go
Comment on lines +456 to +469
func (up *Profile) Validate(v structure.Validator) {
v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen)
v.String("diagnosisType", &up.DiagnosisType).LengthLessThanOrEqualTo(MaxProfileFieldLen)
v.String("targetTimezone", &up.TargetTimezone).LengthLessThanOrEqualTo(MaxProfileFieldLen)
v.String("about", &up.About).LengthLessThanOrEqualTo(MaxProfileFieldLen)
v.String("mrn", &up.MRN).LengthLessThanOrEqualTo(MaxProfileFieldLen)
v.String("biologicalSex", &up.BiologicalSex).LengthLessThanOrEqualTo(MaxProfileFieldLen)

up.Birthday.Validate(v.WithReference("birthday"))
up.DiagnosisDate.Validate(v.WithReference("diagnosisDate"))
if up.DiagnosisType != "" {
v.String("diagnosisType", &up.DiagnosisType).OneOf(DiabetesTypes...)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add the missing length validations.

Profile.Validate bounds every scalar string except the Clinic fields. Profile.Normalize does normalize Clinic, and ToAttributes writes clinic_name, clinic_role, clinic_telephone, and clinic_npi to Keycloak. Those values currently have no length bound. LegacyPatientProfile.Validate also omits biologicalSex, which LegacyPatientProfile.Normalize trims and Profile.Validate bounds. The legacy write path therefore accepts values the new path rejects.

🐛 Proposed fix
 	up.Birthday.Validate(v.WithReference("birthday"))
 	up.DiagnosisDate.Validate(v.WithReference("diagnosisDate"))
 	if up.DiagnosisType != "" {
 		v.String("diagnosisType", &up.DiagnosisType).OneOf(DiabetesTypes...)
 	}
+	if up.Clinic != nil {
+		up.Clinic.Validate(v.WithReference("clinic"))
+	}
 }
+
+func (p *ClinicProfile) Validate(v structure.Validator) {
+	v.String("name", p.Name).LengthLessThanOrEqualTo(MaxProfileFieldLen)
+	v.String("role", p.Role).LengthLessThanOrEqualTo(MaxProfileFieldLen)
+	v.String("telephone", p.Telephone).LengthLessThanOrEqualTo(MaxProfileFieldLen)
+	v.String("npi", p.NPI).LengthLessThanOrEqualTo(MaxProfileFieldLen)
+}
 	v.String("targetTimezone", &pp.TargetTimezone).LengthLessThanOrEqualTo(MaxProfileFieldLen)
 	v.String("about", &pp.About).LengthLessThanOrEqualTo(MaxProfileFieldLen)
 	v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(MaxProfileFieldLen)
+	v.String("biologicalSex", &pp.BiologicalSex).LengthLessThanOrEqualTo(MaxProfileFieldLen)

Also applies to: 519-531

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/profile.go` around lines 456 - 469, Update Profile.Validate to apply
MaxProfileFieldLen validation to all Clinic string fields written by
ToAttributes: clinic_name, clinic_role, clinic_telephone, and clinic_npi. Update
LegacyPatientProfile.Validate to add the same length validation for
biologicalSex, matching Profile.Validate and its normalization behavior.

Comment thread user/timeutil.go
Comment on lines +9 to +20
func ParseTimestamp(timestamp string) (time.Time, error) {
return time.Parse(TimestampFormat, timestamp)
}

func TimestampToUnixString(timestamp string) (unix string, err error) {
parsed, err := ParseTimestamp(timestamp)
if err != nil {
return
}
unix = fmt.Sprintf("%v", parsed.Unix())
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find producers and consumers of TimestampFormat.
set -euo pipefail

rg -n -C 5 'TimestampFormat|ParseTimestamp|TimestampToUnixString|UnixStringToTimestamp' --type=go

Repository: tidepool-org/platform

Length of output: 159


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- candidate files ---'
fd -i 'timeutil.go|user_accessor.go' .
printf '%s\n' '--- timestamp references ---'
rg -n -C 5 'TimestampFormat|ParseTimestamp|TimestampToUnixString|UnixStringToTimestamp' . || true

Repository: tidepool-org/platform

Length of output: 2700


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- time utility ---'
cat -n user/timeutil.go
printf '%s\n' '--- format declaration ---'
cat -n user/user_accessor.go | sed -n '1,25p'
printf '%s\n' '--- consumer context ---'
cat -n user/keycloak/client.go | sed -n '430,475p'
printf '%s\n' '--- related tests and configuration ---'
rg -n -C 4 'timeutil|TimestampToUnixString|UnixStringToTimestamp|ParseTimestamp|createdTimestamp|termsAccepted|TimestampFormat' --glob '*_test.go' --glob 'go.mod' --glob 'go.sum' .
printf '%s\n' '--- standalone Go parser check ---'
if command -v go >/dev/null 2>&1; then
  tmpdir="$(mktemp -d)"
  trap 'rm -rf "$tmpdir"' EXIT
  cat >"$tmpdir/check.go" <<'EOF'
package main

import (
	"fmt"
	"time"
)

func main() {
	const layout = "2006-01-02T15:04:05-07:00"
	for _, input := range []string{
		"2024-01-02T03:04:05Z",
		"2024-01-02T03:04:05+00:00",
		"2024-01-02T03:04:05-05:00",
	} {
		parsed, err := time.Parse(layout, input)
		fmt.Printf("%q => %s, %v\n", input, parsed.Format(time.RFC3339), err)
	}
}
EOF
  (cd "$tmpdir" && go run check.go)
else
  printf '%s\n' 'go is unavailable'
fi

Repository: tidepool-org/platform

Length of output: 7969


Support RFC 3339 UTC timestamps or define a numeric-offset contract.

time.Parse with TimestampFormat rejects valid timestamps such as 2024-01-02T03:04:05Z. Use time.RFC3339 and add a Z test if RFC 3339 input is supported. No in-repository producer establishes that inputs always use numeric offsets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/timeutil.go` around lines 9 - 20, Update ParseTimestamp to parse
timestamps with time.RFC3339 so valid UTC “Z” timestamps are accepted, while
preserving TimestampToUnixString’s existing conversion behavior. Add coverage
for an RFC 3339 input ending in “Z” if tests are available.

Comment thread user/user.go
Comment on lines +40 to +41
IdExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$`)
custodialAccountRegexp = regexp.MustCompile(`(?i)unclaimed-custodial-automation\+\d+@tidepool\.org`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Anchor custodialAccountRegexp.

The pattern is unanchored, so MatchString matches any address that contains the pattern as a substring. An address such as victim+unclaimed-custodial-automation+1@tidepool.org.example.com is classified as an unclaimed custodial email. Anchor the expression to the whole address.

🔒 Proposed fix
-	custodialAccountRegexp = regexp.MustCompile(`(?i)unclaimed-custodial-automation\+\d+@tidepool\.org`)
+	custodialAccountRegexp = regexp.MustCompile(`(?i)^unclaimed-custodial-automation\+\d+@tidepool\.org$`)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
IdExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$`)
custodialAccountRegexp = regexp.MustCompile(`(?i)unclaimed-custodial-automation\+\d+@tidepool\.org`)
IdExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-F]{4}\-[0-9a-F]{12})$`)
custodialAccountRegexp = regexp.MustCompile(`(?i)^unclaimed-custodial-automation\+\d+@tidepool\.org$`)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/user.go` around lines 40 - 41, Update custodialAccountRegexp to anchor
the pattern at both the beginning and end of the string, so MatchString only
accepts the complete unclaimed-custodial address and rejects addresses with
surrounding or trailing content.

Comment thread user/user.go
Comment on lines +143 to +151
func (u *TrustUser) Sanitize(details request.AuthDetails) error {
if details == nil || (!details.IsService() && details.UserID() != *u.UserID) {
// Note that a TrustUser includes some fields in the user that [User.Sanitize] wouldn't.
if (u.TrustorPermissions == nil || len(*u.TrustorPermissions) == 0) && u.User.Profile != nil {
u.User.Profile.Sanitize()
}
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against a nil UserID before dereferencing.

Line 144 dereferences *u.UserID. If any accessor returns a user without an ID, this panics inside the HTTP handler that serves /v1/users/:userId/users. Treat a nil UserID as "not the requesting user".

🐛 Proposed fix
 func (u *TrustUser) Sanitize(details request.AuthDetails) error {
-	if details == nil || (!details.IsService() && details.UserID() != *u.UserID) {
+	if details == nil || !details.IsService() && (u.UserID == nil || details.UserID() != *u.UserID) {
 		// Note that a TrustUser includes some fields in the user that [User.Sanitize] wouldn't.
 		if (u.TrustorPermissions == nil || len(*u.TrustorPermissions) == 0) && u.User.Profile != nil {
 			u.User.Profile.Sanitize()
 		}
 	}
 	return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (u *TrustUser) Sanitize(details request.AuthDetails) error {
if details == nil || (!details.IsService() && details.UserID() != *u.UserID) {
// Note that a TrustUser includes some fields in the user that [User.Sanitize] wouldn't.
if (u.TrustorPermissions == nil || len(*u.TrustorPermissions) == 0) && u.User.Profile != nil {
u.User.Profile.Sanitize()
}
}
return nil
}
func (u *TrustUser) Sanitize(details request.AuthDetails) error {
if details == nil || !details.IsService() && (u.UserID == nil || details.UserID() != *u.UserID) {
// Note that a TrustUser includes some fields in the user that [User.Sanitize] wouldn't.
if (u.TrustorPermissions == nil || len(*u.TrustorPermissions) == 0) && u.User.Profile != nil {
u.User.Profile.Sanitize()
}
}
return nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@user/user.go` around lines 143 - 151, Update the condition in
TrustUser.Sanitize to check u.UserID for nil before dereferencing it, treating a
nil UserID as not the requesting user while preserving the existing service-user
and matching-ID behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants