Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/org/teams_handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/org/teams_handler.go |
| Original SHA-256 | a1aaf4e691e43b10acc4648c5337e5a3ae59d8a3b963fd8994ac1e2eb8b87e09 |
| Git blob SHA | 798ec3dfd5da8abe7d6fa75786c733de4dcd1a54 |
| Reading-copy SHA-256 | a1aaf4e691e43b10acc4648c5337e5a3ae59d8a3b963fd8994ac1e2eb8b87e09 |
Recovered source transcript
Download complete source text ↓438 lines · 14.5 KB
package org
// TEAMS, the unit a desk belongs to.
//
// Team scope in migration 0088 means "records owned by anyone who shares a team with me", so without a way to
// create a team and put people on it, that scope resolves to the caller alone and the setting is a trap. This
// is the surface that makes it real.
//
// A PERSON MAY BE ON MORE THAN ONE TEAM, deliberately. Agencies run people across desks, and a model that
// forces one team per person makes the common case a lie. Team scope is therefore the union of every team the
// caller belongs to.
//
// TEAM LEAD IS A LABEL, NOT AN AUTHORITY. Making somebody a lead does not promote them: what they may do is
// still decided by the role ladder and the capability matrix. This is the mistake that turns an org chart
// feature into a privilege escalation path, and it is avoided by simply not wiring the lead to anything.
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"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"
)
type TeamsHandler struct {
db *tenant.DB
aud *audit.Writer
}
func NewTeamsHandler(db *tenant.DB, aud *audit.Writer) *TeamsHandler {
return &TeamsHandler{db: db, aud: aud}
}
func (h *TeamsHandler) Routes(r chi.Router) {
// READING the desks is open to everyone who can read a candidate. Knowing which desk a colleague sits on
// is org chart information, not privileged data, and hiding it would make the scope setting inexplicable
// to the people it applies to.
read := security.RequireCapability(security.CapCandidateRead)
// WRITING is org.settings.write, the owner tier. Team membership decides what people can see once record
// scope is narrowed, so adding somebody to a team is an access grant and belongs at the same authority as
// the setting that gives it meaning.
write := security.RequireCapability(security.CapOrgSettings)
r.With(read).Get("/teams", h.list)
r.With(read).Get("/teams/mine", h.mine)
r.With(write).Post("/teams", h.create)
r.With(write).Patch("/teams/{teamID}", h.update)
r.With(write).Post("/teams/{teamID}/members", h.addMember)
r.With(write).Delete("/teams/{teamID}/members/{userID}", h.removeMember)
}
type teamMember struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
IsLead bool `json:"is_lead"`
Disabled bool `json:"disabled"`
}
type team struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
LeadID string `json:"lead_user_id,omitempty"`
LeadName string `json:"lead_name,omitempty"`
Members []teamMember `json:"members"`
}
func (h *TeamsHandler) loadTeams(ctx context.Context, tx pgx.Tx, onlyUser *uuid.UUID) ([]team, error) {
q := `
SELECT t.id::text, t.name, t.status, coalesce(t.lead_user_id::text,''), coalesce(l.full_name,'')
FROM teams t
LEFT JOIN users l ON l.id = t.lead_user_id
WHERE ($1::uuid IS NULL OR EXISTS (
SELECT 1 FROM team_members m WHERE m.team_id = t.id AND m.user_id = $1))
ORDER BY t.status, lower(t.name)`
// A TYPED pointer, not an untyped nil in an any. pgx cannot infer a parameter type from a nil interface,
// so the untyped form made every read of this route a 500 while the writes worked perfectly, which is
// exactly the asymmetry that made it invisible until a recruiter tried to look at the desks.
rows, err := tx.Query(ctx, q, onlyUser)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]team, 0, 16)
idx := map[string]int{}
for rows.Next() {
var t team
if err := rows.Scan(&t.ID, &t.Name, &t.Status, &t.LeadID, &t.LeadName); err != nil {
return nil, err
}
t.Members = []teamMember{}
idx[t.ID] = len(out)
out = append(out, t)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if len(out) == 0 {
return out, nil
}
// Members are loaded in ONE query rather than per team, so a floor with thirty desks is two round trips
// and not thirty one.
mrows, err := tx.Query(ctx, `
SELECT m.team_id::text, m.user_id::text, coalesce(u.full_name,''), coalesce(u.email,''),
coalesce(u.role,''),
-- COALESCED TO false. A team with no lead makes "lead_user_id = user_id" NULL rather than
-- false, and scanning a NULL into a Go bool is an error, so a desk without a named lead made
-- the whole read a 500 while every write kept working. That asymmetry is why it survived
-- until somebody tried to look at the desks.
coalesce(t.lead_user_id = m.user_id, false),
coalesce(u.status <> 'active', false)
FROM team_members m
JOIN teams t ON t.id = m.team_id
LEFT JOIN users u ON u.id = m.user_id
ORDER BY lower(coalesce(u.full_name,''))`)
if err != nil {
return nil, err
}
defer mrows.Close()
for mrows.Next() {
var tid string
var m teamMember
if err := mrows.Scan(&tid, &m.UserID, &m.Name, &m.Email, &m.Role, &m.IsLead, &m.Disabled); err != nil {
return nil, err
}
if i, ok := idx[tid]; ok {
out[i].Members = append(out[i].Members, m)
}
}
return out, mrows.Err()
}
func (h *TeamsHandler) list(w http.ResponseWriter, r *http.Request) {
var out []team
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
var e error
out, e = h.loadTeams(ctx, tx, nil)
return e
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not read teams")
return
}
writeJSON(w, http.StatusOK, map[string]any{"teams": out, "count": len(out)})
}
func (h *TeamsHandler) mine(w http.ResponseWriter, r *http.Request) {
actor := actorUUID(r.Context())
if actor == uuid.Nil {
writeErr(w, http.StatusForbidden, "no principal")
return
}
var out []team
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
var e error
out, e = h.loadTeams(ctx, tx, &actor)
return e
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not read teams")
return
}
writeJSON(w, http.StatusOK, map[string]any{"teams": out, "count": len(out)})
}
type teamRequest struct {
Name string `json:"name"`
LeadUserID *string `json:"lead_user_id"`
Status *string `json:"status"`
}
func (h *TeamsHandler) create(w http.ResponseWriter, r *http.Request) {
var in teamRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > 120 {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a team needs a name of up to 120 characters", "field": "name"})
return
}
lead, ok := parseOptionalUUID(w, in.LeadUserID)
if !ok {
return
}
actor := actorUUID(r.Context())
var id uuid.UUID
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
e := tx.QueryRow(ctx, `
INSERT INTO teams (organization_id, name, lead_user_id)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2)
RETURNING id`, in.Name, lead).Scan(&id)
if e != nil {
return e
}
// The lead is added as a member automatically. A lead who is not on their own team would be excluded
// from their own desk's records the moment scope is narrowed, which nobody would ever intend.
if lead != nil {
if _, e := tx.Exec(ctx, `
INSERT INTO team_members (organization_id, team_id, user_id, added_by)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3)
ON CONFLICT DO NOTHING`, id, *lead, nullUUID(actor)); e != nil {
return e
}
}
return h.auditTeam(ctx, tx, r, actor, "org.team.created", id, map[string]any{"name": in.Name})
})
if err != nil {
if isUnique(err) {
writeErr(w, http.StatusConflict, "a team with that name already exists")
return
}
writeErr(w, http.StatusInternalServerError, "could not create the team")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id.String(), "name": in.Name})
}
func (h *TeamsHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := parsePathUUID(w, r, "teamID")
if !ok {
return
}
var in teamRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
if in.Status != nil && *in.Status != "active" && *in.Status != "archived" {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a team is either active or archived", "field": "status"})
return
}
lead, ok := parseOptionalUUID(w, in.LeadUserID)
if !ok {
return
}
actor := actorUUID(r.Context())
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
set := []string{"updated_at = now()"}
args := []any{id}
if n := strings.TrimSpace(in.Name); n != "" {
args = append(args, n)
set = append(set, "name = $2")
}
if in.Status != nil {
args = append(args, *in.Status)
set = append(set, "status = $"+itoa(len(args)))
}
if in.LeadUserID != nil {
args = append(args, lead)
set = append(set, "lead_user_id = $"+itoa(len(args)))
}
tag, e := tx.Exec(ctx, `UPDATE teams SET `+strings.Join(set, ", ")+` WHERE id = $1`, args...)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return errNoTeam
}
return h.auditTeam(ctx, tx, r, actor, "org.team.updated", id, map[string]any{
"name": in.Name, "status": in.Status,
})
})
if err != nil {
h.failTeam(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"updated": true})
}
func (h *TeamsHandler) addMember(w http.ResponseWriter, r *http.Request) {
id, ok := parsePathUUID(w, r, "teamID")
if !ok {
return
}
var in struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
member, err := uuid.Parse(strings.TrimSpace(in.UserID))
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a user identifier is required", "field": "user_id"})
return
}
actor := actorUUID(r.Context())
err = h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
// The composite foreign keys already refuse a team or a user from another tenant, and RLS means a
// row from elsewhere is simply not there. This exists so the caller gets a sentence rather than a
// constraint violation.
var one int
if e := tx.QueryRow(ctx, `SELECT 1 FROM teams WHERE id = $1`, id).Scan(&one); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return errNoTeam
}
return e
}
if e := tx.QueryRow(ctx, `SELECT 1 FROM users WHERE id = $1`, member).Scan(&one); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return errNoUser
}
return e
}
if _, e := tx.Exec(ctx, `
INSERT INTO team_members (organization_id, team_id, user_id, added_by)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3)
ON CONFLICT DO NOTHING`, id, member, nullUUID(actor)); e != nil {
return e
}
// AUDITED AS A PERMISSION CHANGE, because under a narrowed record scope that is exactly what it is:
// this line decides which records the person can see.
return h.auditTeam(ctx, tx, r, actor, "org.team.member_added", id,
map[string]any{"user_id": member.String()})
})
if err != nil {
h.failTeam(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"added": true})
}
func (h *TeamsHandler) removeMember(w http.ResponseWriter, r *http.Request) {
id, ok := parsePathUUID(w, r, "teamID")
if !ok {
return
}
member, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
writeErr(w, http.StatusBadRequest, "that is not a user identifier")
return
}
actor := actorUUID(r.Context())
err = h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
if _, e := tx.Exec(ctx, `DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`, id, member); e != nil {
return e
}
// Removing the lead from the team clears the lead, so a team cannot be led by somebody who is not on
// it. Left alone, that state would render as a lead name on a desk they have no access to.
if _, e := tx.Exec(ctx, `UPDATE teams SET lead_user_id = NULL WHERE id = $1 AND lead_user_id = $2`, id, member); e != nil {
return e
}
return h.auditTeam(ctx, tx, r, actor, "org.team.member_removed", id,
map[string]any{"user_id": member.String()})
})
if err != nil {
h.failTeam(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"removed": true})
}
var (
errNoTeam = errors.New("org: no such team")
errNoUser = errors.New("org: no such user")
)
func (h *TeamsHandler) failTeam(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, errNoTeam):
writeErr(w, http.StatusNotFound, "no such team")
case errors.Is(err, errNoUser):
writeErr(w, http.StatusNotFound, "no such user in this organization")
case isUnique(err):
writeErr(w, http.StatusConflict, "a team with that name already exists")
default:
writeErr(w, http.StatusInternalServerError, "could not update the team")
}
}
func (h *TeamsHandler) auditTeam(ctx context.Context, tx pgx.Tx, r *http.Request, actor uuid.UUID,
action string, id uuid.UUID, body map[string]any) error {
if h.aud == nil {
return nil
}
b, _ := json.Marshal(body)
return h.aud.OnTx(ctx, tx, audit.Event{
Action: action, ActorUserID: &actor, EntityType: "team", EntityID: id.String(),
NewValues: b, IPAddress: r.Header.Get("X-Forwarded-For"), UserAgent: r.UserAgent(),
})
}
func parsePathUUID(w http.ResponseWriter, r *http.Request, name string) (uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, name))
if err != nil {
writeErr(w, http.StatusBadRequest, "that is not an identifier")
return uuid.Nil, false
}
return id, true
}
func parseOptionalUUID(w http.ResponseWriter, s *string) (*uuid.UUID, bool) {
if s == nil || strings.TrimSpace(*s) == "" {
return nil, true
}
id, err := uuid.Parse(strings.TrimSpace(*s))
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "that is not a user identifier", "field": "lead_user_id"})
return nil, false
}
return &id, true
}
func nullUUID(u uuid.UUID) any {
if u == uuid.Nil {
return nil
}
return u
}
func isUnique(err error) bool {
m := err.Error()
return strings.Contains(m, "duplicate key") || strings.Contains(m, "unique constraint")
}
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}