Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/auth/profile.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/auth/profile.go |
| Original SHA-256 | de65ae9cd5526e0df058079c16fc9741a9f9ffd690d9e62d36419f70099b9778 |
| Git blob SHA | 5605efd64a7d6ce5694ca05efca8e85f41aea308 |
| Reading-copy SHA-256 | de65ae9cd5526e0df058079c16fc9741a9f9ffd690d9e62d36419f70099b9778 |
Recovered source transcript
Download complete source text ↓186 lines · 7.9 KB
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
}