package auth import ( "context" "encoding/json" "errors" "fmt" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Self service profile and tenant name editing, amendment 6 decisions 4 and 5, Track U items 2 and 3. // // The defect this closes: full_name was written once at invite acceptance and never again, by anyone. There // was no self service endpoint and no administrative one either, so a trivial personal edit had no path at // all and escalated to the top of the ladder, where it still failed. Two writes fix it, and they are // deliberately separate because they answer to different rules: // // UpdateOwnProfile anyone, on THEMSELVES only. No capability needed, because editing your own name is // not an act of authority. The subject is taken from the session, never from the body, // so it cannot be pointed at another account. // UpdateUserProfile admin and above, on someone they OUTRANK, within their own tenant. This is the // client facing fix, so an admin can correct a colleague's name today. // // Both audit as identity change events, and both are bounded by the same length caps the schema enforces. // The package already declares ErrNotFound; a user in another tenant is indistinguishable from one that // does not exist, which is the intent. // ErrOutranked is returned when an actor tries to edit someone at or above their own rank. It is the same // separation of duties that governs role changes: nobody edits a peer's or a superior's identity. var ErrOutranked = errors.New("auth: you may only edit a user you outrank") const ( maxNameLen = 120 maxTitleLen = 120 maxPhoneLen = 40 ) // ProfileInput carries the editable profile fields. A nil pointer leaves the column unchanged, so a caller // sends only what it means to change and an omitted field is never silently blanked. type ProfileInput struct { FullName *string `json:"full_name"` Title *string `json:"title"` Phone *string `json:"phone"` } // Profile is the app facing view of an identity. type Profile struct { ID string `json:"id"` Email string `json:"email"` FullName string `json:"full_name"` Title string `json:"title"` Phone string `json:"phone"` Role string `json:"role"` Status string `json:"status"` } // UpdateOwnProfile edits the CALLER's own identity. The subject is the session's user id, never a value // from the request, so this endpoint cannot be aimed at another account no matter what the body says. func (s *Service) UpdateOwnProfile(ctx context.Context, userID, orgID string, in ProfileInput) (Profile, error) { return s.writeProfile(ctx, userID, orgID, userID, in, "identity.profile.self_updated") } // UpdateUserProfile edits ANOTHER user's identity within the actor's tenant. The caller must hold // user.role.change (admin and above) and must outrank the target, both enforced here rather than only in // the UI, so an API caller is bound by the same ceiling as a button. func (s *Service) UpdateUserProfile(ctx context.Context, actorID, actorRole, orgID, targetID string, in ProfileInput) (Profile, error) { targetRole, err := s.roleOf(ctx, targetID, orgID) if err != nil { return Profile{}, err } if !security.OutRanks(security.Role(actorRole), security.Role(targetRole)) { return Profile{}, ErrOutranked } return s.writeProfile(ctx, actorID, orgID, targetID, in, "identity.profile.admin_updated") } // writeProfile is the one write both paths share, so the validation, the audit shape, and the returned view // cannot drift between self service and administrative editing. func (s *Service) writeProfile(ctx context.Context, actorID, orgID, targetID string, in ProfileInput, action string) (Profile, error) { sets := make([]string, 0, 3) args := make([]any, 0, 5) add := func(col string, v string, max int, label string) error { v = strings.TrimSpace(v) if len(v) > max { return fmt.Errorf("%s exceeds %d characters", label, max) } args = append(args, v) sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) return nil } if in.FullName != nil { if strings.TrimSpace(*in.FullName) == "" { return Profile{}, errors.New("full_name cannot be empty") } if err := add("full_name", *in.FullName, maxNameLen, "full_name"); err != nil { return Profile{}, err } } if in.Title != nil { if err := add("title", *in.Title, maxTitleLen, "title"); err != nil { return Profile{}, err } } if in.Phone != nil { if err := add("phone", *in.Phone, maxPhoneLen, "phone"); err != nil { return Profile{}, err } } if len(sets) == 0 { return s.getProfile(ctx, targetID, orgID) } sets = append(sets, "updated_at = now()") args = append(args, targetID, orgID) q := `UPDATE users SET ` + strings.Join(sets, ", ") + fmt.Sprintf(" WHERE id = $%d AND organization_id = $%d", len(args)-1, len(args)) + ` RETURNING id::text, email, full_name, title, phone, role, status` // The write runs INSIDE a tenant transaction, so app.current_org_id is set for it. // // This is not belt and braces, it is the difference between working and not. users carries FORCED row // level security, so the same statement on a bare pool connection with no org GUC matches ZERO rows and // the caller sees "user not found" for a user that plainly exists, on their own account. The explicit // organization_id predicate in the WHERE clause looks like it is doing the scoping, and it is not: RLS // filters the row out before the predicate is ever considered. Proven on staging, where PATCH /me/profile // answered 404 for the signed in user until this ran in a transaction. // // The org is the one already validated onto the context by the session middleware, never a request value. var p Profile if err := s.deps.DB.RunInTenantTx(tenant.WithOrg(ctx, orgID), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, q, args...).Scan(&p.ID, &p.Email, &p.FullName, &p.Title, &p.Phone, &p.Role, &p.Status) }); err != nil { return Profile{}, ErrNotFound } // Identity changes are audited as their own event class. The new values are recorded so a later reader // can see WHAT the identity became; the actor and the subject are both named, which is what makes an // administrative edit distinguishable from a self edit in the trail. nv, _ := json.Marshal(map[string]any{ "subject_user_id": p.ID, "full_name": p.FullName, "title": p.Title, "phone": p.Phone, }) actor, perr := uuid.Parse(actorID) ev := audit.Event{Action: action, EntityType: "user", EntityID: p.ID, NewValues: nv} if perr == nil { ev.ActorUserID = &actor } if s.deps.Audit != nil { _ = s.deps.Audit.System(ctx, ev) } return p, nil } // getProfile and roleOf read the same RLS protected table as the write, so they run in a tenant // transaction for the same reason: without the org GUC they return no rows and report a real user as // missing. func (s *Service) getProfile(ctx context.Context, userID, orgID string) (Profile, error) { var p Profile err := s.deps.DB.RunInTenantTx(tenant.WithOrg(ctx, orgID), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT id::text, email, full_name, title, phone, role, status FROM users WHERE id=$1 AND organization_id=$2`, userID, orgID).Scan(&p.ID, &p.Email, &p.FullName, &p.Title, &p.Phone, &p.Role, &p.Status) }) if err != nil { return Profile{}, ErrNotFound } return p, nil } func (s *Service) roleOf(ctx context.Context, userID, orgID string) (string, error) { var role string err := s.deps.DB.RunInTenantTx(tenant.WithOrg(ctx, orgID), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT role FROM users WHERE id=$1 AND organization_id=$2`, userID, orgID).Scan(&role) }) if err != nil { return "", ErrNotFound } return role, nil }