Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/org/settings_handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/org/settings_handler.go |
| Original SHA-256 | 9867b47bdb6e650cbbdbacf485f0155ac5f111072b89c06557dc454447904ad8 |
| Git blob SHA | c4fce1234b9dba26e1acd3f3f4e7e1123653282d |
| Reading-copy SHA-256 | 21434fe0495336c6799dea9fe2a4ae53f4c61a2ca1694d045590bc83c650ccae |
Recovered source transcript
Download complete source text ↓532 lines · 21.8 KB
Credential-bearing and unrelated personal details were removed from this reading copy. The source identity and original checksum remain recorded.
package org
// THE SETTINGS SURFACE, and the reason it lives beside branding rather than in a module of its own: this is
// the same authority (org.settings.write, held by the owner) over the same subject (how this organization
// works), and splitting it would have meant a second module importing the first for no gain.
//
// TWO AUDIENCES, TWO ROUTES, AND THE DIFFERENCE IS LOAD BEARING.
//
// GET /org/settings is the ADMINISTRATOR'S view: every configured value, gated on org.settings.write. It
// still contains no credential, because the settings type cannot hold one.
//
// GET /org/policy is EVERY USER'S view, gated only on being able to read a candidate. It returns no settings
// at all. It returns the ANSWERS, already computed for the caller: may I see rates, may I see who else is
// working this. The interface needs those answers to render, and handing a recruiter the whole configuration
// so their browser can work it out would both leak how the organization is set up and put the decision on
// the client, where it is advice rather than enforcement. The server decides; the client is told.
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/shopspring/decimal"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/mailer"
"github.com/missionworkforce/missionmatch/internal/platform/orgsettings"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// SettingsHandler serves the settings surface. store may be nil on a node built without it, in which case
// every route reports itself unavailable rather than pretending to save.
type SettingsHandler struct {
db *tenant.DB
store *orgsettings.Store
aud *audit.Writer
}
func NewSettingsHandler(db *tenant.DB, store *orgsettings.Store, aud *audit.Writer) *SettingsHandler {
return &SettingsHandler{db: db, store: store, aud: aud}
}
func (h *SettingsHandler) Routes(r chi.Router) {
r.With(security.RequireCapability(security.CapCandidateRead)).Get("/org/policy", h.policy)
r.With(security.RequireCapability(security.CapOrgSettings)).Get("/org/settings", h.get)
r.With(security.RequireCapability(security.CapOrgSettings)).Patch("/org/settings", h.patch)
r.With(security.RequireCapability(security.CapOrgSettings)).Put("/org/settings/email/password", h.putPassword)
r.With(security.RequireCapability(security.CapOrgSettings)).Delete("/org/settings/email/password", h.deletePassword)
r.With(security.RequireCapability(security.CapOrgSettings)).Post("/org/settings/email/test", h.testEmail)
}
func (h *SettingsHandler) get(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeErr(w, http.StatusServiceUnavailable, "settings are unavailable on this node")
return
}
st, err := h.store.Load(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not read settings")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": st,
// Told plainly, so an administrator is not asked to type a password this node cannot store.
"secret_storage_available": h.store.SealAvailable(),
})
}
// policyResponse is what the interface renders against. Every field is a decision already made for THIS
// caller, never a setting they could reinterpret.
type policyResponse struct {
CanSeeRates bool `json:"can_see_rates"`
CanSeeMargins bool `json:"can_see_margins"`
CanSeeContacts bool `json:"can_see_contacts"`
CanSeeWorkOf bool `json:"can_see_work_of_others"`
ShowPresence bool `json:"show_activity_presence"`
ClaimRequired bool `json:"claim_required"`
ClaimExpiryHrs int `json:"claim_expiry_hours"`
EmailDelivery bool `json:"email_delivery_enabled"`
// NoticeRequired says the organization refuses to score a candidate with no automated decision notice on
// file. Every role is told, because it is a recruiter who meets the refusal and a recruiter who has to
// know why before they meet it rather than after.
NoticeRequired bool `json:"aedt_notice_required"`
// TimeZone comes from organizations.time_zone, the column that has held it since migration 0057 and
// already decides what "today" means for the task queue. It is reported here so a screen rendering a
// deadline does not have to ask a second endpoint for it.
TimeZone string `json:"time_zone"`
// RecordScope is the caller's RESOLVED scope, not the organization's setting. The interface uses it to
// explain a short list honestly, so a recruiter sees "showing your records" rather than concluding the
// bench is empty.
RecordScope string `json:"record_scope"`
}
func (h *SettingsHandler) policy(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeErr(w, http.StatusServiceUnavailable, "settings are unavailable on this node")
return
}
p, ok := tenant.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusForbidden, "no principal")
return
}
st, err := h.store.Load(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not read settings")
return
}
orgTimeZone := "America/New_York"
_ = h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `SELECT time_zone FROM organizations LIMIT 1`).Scan(&orgTimeZone)
})
role := security.Role(p.Role)
writeJSON(w, http.StatusOK, policyResponse{
CanSeeRates: security.CanSeeField(role, st.RateVisibility),
CanSeeMargins: security.CanSeeField(role, st.MarginVisibility),
CanSeeContacts: security.CanSeeField(role, st.ContactVisibility),
CanSeeWorkOf: security.CanSeeOthersWork(role, st.WorkVisibility),
ShowPresence: st.ShowActivityPresence,
ClaimRequired: st.ClaimRequired,
ClaimExpiryHrs: st.ClaimExpiryHours,
EmailDelivery: st.EmailDeliveryEnabled && st.SMTPVerifiedAt != nil,
TimeZone: orgTimeZone,
RecordScope: string(security.ResolveRecordScope(role, st.StaffRecordScope, st.ManagerRecordScope)),
NoticeRequired: st.AEDTNoticeRequired,
})
}
// patchRequest mirrors orgsettings.Update with JSON tags. Pointers throughout, so a field the administrator
// did not touch is absent rather than zero, and a partial save cannot blank a colleague's setting.
type patchRequest struct {
EmailDeliveryEnabled *bool `json:"email_delivery_enabled"`
EmailFromAddress *string `json:"email_from_address"`
EmailFromName *string `json:"email_from_name"`
EmailReplyTo *string `json:"email_reply_to"`
SMTPHost *string `json:"smtp_host"`
SMTPPort *int `json:"smtp_port"`
SMTPUsername *string `json:"smtp_username"`
SMTPSecurity *string `json:"smtp_security"`
WorkVisibility *string `json:"work_visibility"`
ShowActivityPresence *bool `json:"show_activity_presence"`
ClaimRequired *bool `json:"claim_required"`
ClaimExpiryHours *int `json:"claim_expiry_hours"`
RateVisibility *string `json:"rate_visibility"`
MarginVisibility *string `json:"margin_visibility"`
ContactVisibility *string `json:"contact_visibility"`
StaffRecordScope *string `json:"staff_record_scope"`
ManagerRecordScope *string `json:"manager_record_scope"`
UnownedRecordsVisible *bool `json:"unowned_records_visible"`
InviteExpiryHours *int `json:"invite_expiry_hours"`
DefaultInviteRole *string `json:"default_invite_role"`
// DefaultBurdenPct arrives as a STRING, never a JSON number, for the same reason every other rate in this
// product does: a wrap that round tripped through a float does not reconcile with an invoice.
//
// The double pointer is what carries the three meanings. Field absent, so the outer pointer is nil, means
// leave the wrap alone. Field present as JSON null, so the outer is set and the inner is nil, means clear
// it. Field present as a string sets it. Without the distinction, clearing and not touching look identical
// on the wire and an owner cannot remove a wrap they entered by mistake.
DefaultBurdenPct *string `json:"default_burden_pct"`
// AEDTNoticeRequired refuses to score a candidate with no automated decision notice on file.
AEDTNoticeRequired *bool `json:"aedt_notice_required"`
// defaultBurdenPresent records whether the KEY appeared at all, which a *string cannot express: a JSON null
// and an absent key both decode to nil. UnmarshalJSON below is the only place it is set.
defaultBurdenPresent bool
}
// UnmarshalJSON exists ONLY to tell an absent default_burden_pct apart from an explicit null.
//
// encoding/json decodes both to a nil pointer, and the two mean opposite things here: one leaves the
// organization's wrap untouched, the other erases it and takes every margin in the product with it. A settings
// PATCH that sends a subset of fields, which is the normal case and is what the interface does, would
// otherwise clear the wrap on every unrelated save.
func (p *patchRequest) UnmarshalJSON(b []byte) error {
type raw patchRequest
var r raw
if err := json.Unmarshal(b, &r); err != nil {
return err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(b, &keys); err != nil {
return err
}
*p = patchRequest(r)
_, p.defaultBurdenPresent = keys["default_burden_pct"]
return nil
}
func (p patchRequest) toUpdate() orgsettings.Update {
return orgsettings.Update{
EmailDeliveryEnabled: p.EmailDeliveryEnabled,
EmailFromAddress: p.EmailFromAddress,
EmailFromName: p.EmailFromName,
EmailReplyTo: p.EmailReplyTo,
SMTPHost: p.SMTPHost,
SMTPPort: p.SMTPPort,
SMTPUsername: p.SMTPUsername,
SMTPSecurity: p.SMTPSecurity,
WorkVisibility: p.WorkVisibility,
ShowActivityPresence: p.ShowActivityPresence,
ClaimRequired: p.ClaimRequired,
ClaimExpiryHours: p.ClaimExpiryHours,
RateVisibility: p.RateVisibility,
MarginVisibility: p.MarginVisibility,
ContactVisibility: p.ContactVisibility,
StaffRecordScope: p.StaffRecordScope,
ManagerRecordScope: p.ManagerRecordScope,
UnownedRecordsVisible: p.UnownedRecordsVisible,
InviteExpiryHours: p.InviteExpiryHours,
DefaultInviteRole: p.DefaultInviteRole,
DefaultBurdenPct: p.burdenUpdate(),
AEDTNoticeRequired: p.AEDTNoticeRequired,
}
}
// burdenUpdate turns the wire shape into the three state update the store expects. A malformed number is
// deliberately NOT handled here: it becomes a nil inner pointer, which would read as a clear. parseBurden
// below is called first by the handler so a bad string is refused with a field name rather than silently
// erasing the wrap.
func (p patchRequest) burdenUpdate() **decimal.Decimal {
if !p.defaultBurdenPresent {
return nil
}
var inner *decimal.Decimal
if p.DefaultBurdenPct != nil && strings.TrimSpace(*p.DefaultBurdenPct) != "" {
if d, err := decimal.NewFromString(strings.TrimSpace(*p.DefaultBurdenPct)); err == nil {
inner = &d
}
}
return &inner
}
// parseBurden reports whether the supplied wrap is a number at all, so the handler can refuse a typo with the
// offending field named rather than treating it as a request to clear the wrap.
func (p patchRequest) parseBurden() error {
if !p.defaultBurdenPresent || p.DefaultBurdenPct == nil {
return nil
}
v := strings.TrimSpace(*p.DefaultBurdenPct)
if v == "" {
return nil
}
if _, err := decimal.NewFromString(v); err != nil {
return fmt.Errorf("that is not a number, a 35 percent wrap is written 0.35")
}
return nil
}
func (h *SettingsHandler) patch(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeErr(w, http.StatusServiceUnavailable, "settings are unavailable on this node")
return
}
var in patchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
// A MALFORMED WRAP IS REFUSED BEFORE IT CAN READ AS A CLEAR. burdenUpdate turns an unparseable string into
// a nil inner pointer, which the store treats as "erase the wrap", so a typo would silently remove the
// organization's cost basis and turn every margin in the product back into a spread. Checking first means
// the owner gets the field name and the wrap survives.
if err := in.parseBurden(); err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": err.Error(), "field": "default_burden_pct"})
return
}
u := in.toUpdate()
if field, err := u.Validate(); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error(), "field": field})
return
}
actor := actorUUID(r.Context())
var after orgsettings.Settings
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
before, e := h.store.LoadTx(ctx, tx)
if e != nil {
return e
}
after, e = h.store.Save(ctx, tx, u, actor)
if e != nil {
return e
}
// AUDITED WITH BEFORE AND AFTER, because these settings decide who can see pay data and whose activity
// is visible to whom. A change to rate_visibility is a permission change, and permission changes are
// exactly what the audit rule names.
if h.aud != nil {
b, _ := json.Marshal(before)
a, _ := json.Marshal(after)
return h.aud.OnTx(ctx, tx, audit.Event{
Action: "org.settings.updated", ActorUserID: &actor,
EntityType: "org_settings", OldValues: b, NewValues: a,
IPAddress: r.Header.Get("X-Forwarded-For"), UserAgent: r.UserAgent(),
})
}
return nil
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not save settings")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": after})
}
func (h *SettingsHandler) putPassword(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeErr(w, http.StatusServiceUnavailable, "settings are unavailable on this node")
return
}
if !h.store.SealAvailable() {
// A HONEST 503. The alternative, storing it unprotected or dropping it silently, are both worse than
// telling the administrator that this node cannot hold a secret yet.
writeErr(w, http.StatusServiceUnavailable,
"this node cannot store a secret because no organization secret key is configured, contact your operator")
return
}
var in struct {
Password string `json:"password"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(in.Password) == "" {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a password is [credential removed], use DELETE to remove the stored one", "field": "password"})
return
}
actor := actorUUID(r.Context())
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
if e := h.store.PutSecret(ctx, tx, orgsettings.SecretSMTPPassword, in.Password, actor); e != nil {
return e
}
// Storing a new password invalidates any previous proof that mail worked, for the same reason changing
// the host does.
if e := h.store.MarkAttemptFailed(ctx, tx, "the mail password changed, send a test message to verify it", "", ""); e != nil {
return e
}
if h.aud != nil {
// NAME ONLY. The audit trail records that a mail credential was set, never any part of it.
body, _ := json.Marshal(map[string]any{"secret": orgsettings.SecretSMTPPassword})
return h.aud.OnTx(ctx, tx, audit.Event{
Action: "org.secret.set", ActorUserID: &actor,
EntityType: "org_secret", EntityID: orgsettings.SecretSMTPPassword, NewValues: body,
IPAddress: r.Header.Get("X-Forwarded-For"), UserAgent: r.UserAgent(),
})
}
return nil
})
if err != nil {
if errors.Is(err, orgsettings.ErrSealUnavailable) {
writeErr(w, http.StatusServiceUnavailable, "secret storage is not configured on this node")
return
}
writeErr(w, http.StatusInternalServerError, "could not store the mail password")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"stored": true,
"note": "the password is [credential removed] at rest and is never returned, send a test message to verify it works",
})
}
func (h *SettingsHandler) deletePassword(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeErr(w, http.StatusServiceUnavailable, "settings are unavailable on this node")
return
}
actor := actorUUID(r.Context())
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
if e := h.store.DeleteSecret(ctx, tx, orgsettings.SecretSMTPPassword); e != nil {
return e
}
// Removing the credential stops delivery. Leaving delivery enabled against a credential that no longer
// exists would queue invites into a failure nobody is watching.
if e := h.store.MarkAttemptFailed(ctx, tx, "the mail password was removed", "", ""); e != nil {
return e
}
if h.aud != nil {
body, _ := json.Marshal(map[string]any{"secret": orgsettings.SecretSMTPPassword})
return h.aud.OnTx(ctx, tx, audit.Event{
Action: "org.secret.removed", ActorUserID: &actor,
EntityType: "org_secret", EntityID: orgsettings.SecretSMTPPassword, OldValues: body,
IPAddress: r.Header.Get("X-Forwarded-For"), UserAgent: r.UserAgent(),
})
}
return nil
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not remove the mail password")
return
}
writeJSON(w, http.StatusOK, map[string]any{"removed": true})
}
// testEmail is the ONLY way smtp_verified_at is ever set, and it sends to the administrator's OWN address,
// never to one supplied in the request. That is deliberate: a test endpoint that accepts a recipient is an
// open relay wearing a settings screen, letting any authenticated tenant admin send mail from this platform
// to anybody they choose.
func (h *SettingsHandler) testEmail(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeErr(w, http.StatusServiceUnavailable, "settings are unavailable on this node")
return
}
actor := actorUUID(r.Context())
var cfg mailer.Config
var recipient string
err := h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
st, e := h.store.LoadTx(ctx, tx)
if e != nil {
return e
}
if st.SMTPHost == "" || st.SMTPPort == 0 || st.EmailFromAddress == "" {
return errNotConfigured
}
pw, e := h.store.OpenSecret(ctx, tx, orgsettings.SecretSMTPPassword)
if e != nil && !errors.Is(e, orgsettings.ErrNoSecret) {
return e
}
if st.SMTPUsername != "" && errors.Is(e, orgsettings.ErrNoSecret) {
return errNoPassword
}
cfg = mailer.Config{
Host: st.SMTPHost, Port: st.SMTPPort, Username: st.SMTPUsername, Password: pw,
Security: st.SMTPSecurity, From: st.EmailFromAddress, FromName: st.EmailFromName,
ReplyTo: st.EmailReplyTo,
}
return tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, actor).Scan(&recipient)
})
if err != nil {
switch {
case errors.Is(err, errNotConfigured):
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "configure a mail host, port and sender address first", "field": "smtp_host"})
case errors.Is(err, errNoPassword):
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a username is set but no password is [credential removed]", "field": "password"})
default:
writeErr(w, http.StatusInternalServerError, "could not read the mail configuration")
}
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
sendErr := mailer.Send(ctx, cfg, mailer.Message{
To: recipient,
Subject: "missionMATCH test message",
Body: "This is a test from missionMATCH.\r\n\r\n" +
"Your organization's mail settings are working. Invitations and notifications will now be sent " +
"from " + cfg.From + " using your own mail server.\r\n\r\n" +
"If you did not request this, someone with administrator access to your missionMATCH account " +
"changed the mail settings.\r\n",
})
// The outcome is recorded either way, in its own transaction, so a failed send still leaves the
// administrator an error they can act on rather than a screen that simply did nothing.
_ = h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
if sendErr != nil {
return h.store.MarkAttemptFailed(ctx, tx, sendErr.Error(), cfg.Username, cfg.Password)
}
return h.store.MarkVerified(ctx, tx)
})
if h.aud != nil {
outcome := "sent"
if sendErr != nil {
outcome = "failed"
}
body, _ := json.Marshal(map[string]any{"outcome": outcome, "host": cfg.Host})
_ = h.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error {
return h.aud.OnTx(ctx, tx, audit.Event{
Action: "org.email.test", ActorUserID: &actor, EntityType: "org_settings",
NewValues: body, IPAddress: r.Header.Get("X-Forwarded-For"), UserAgent: r.UserAgent(),
})
})
}
if sendErr != nil {
// The transport's own words, scrubbed of the credentials, because "could not send mail" sends an
// administrator to a support queue while "no such host" sends them to their typo.
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": redactFor(sendErr.Error(), cfg.Username, cfg.Password),
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"sent": true, "to": recipient,
"note": "mail delivery is now verified, switch on email_delivery_enabled to send invitations this way",
})
}
var (
errNotConfigured = errors.New("mail is not configured")
errNoPassword = [credential removed]"no mail password stored")
)
// redactFor mirrors the scrubbing applied before the message is stored, so the value returned over the API
// and the value written to the column are the same scrubbed text.
func redactFor(s, username, password string) string {
for _, secret := range []string{password, username} {
if len(secret) >= 4 {
s = strings.ReplaceAll(s, secret, "[redacted]")
}
}
return s
}
func actorUUID(ctx context.Context) uuid.UUID {
p, ok := tenant.UserFromContext(ctx)
if !ok {
return uuid.Nil
}
id, err := uuid.Parse(p.UserID)
if err != nil {
return uuid.Nil
}
return id
}