Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/platform/organizations.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/platform/organizations.go |
| Original SHA-256 | 6a795a682a1997d7098e53a93503179546db33df90b08902490591fa8dae9639 |
| Git blob SHA | 7b786c661d0efd31bacd75d0e5ca77004aa8f4b8 |
| Reading-copy SHA-256 | 6a795a682a1997d7098e53a93503179546db33df90b08902490591fa8dae9639 |
Recovered source transcript
Download complete source text ↓274 lines · 10.4 KB
// Package platform is the OPERATOR PLANE: cross tenant surfaces that belong to whoever runs the platform
// rather than to any customer inside it. Everything here mounts inside the RequirePlatform group in
// cmd/api/main.go and inherits that gate rather than remembering it individually, which is what the comment
// there asks for.
//
// WHY THIS EXISTS. Creating a customer required SSH to a production server and running cmd/provision-org.
// Loading their bench required cmd/import-candidates. So onboarding, the single thing that has to happen
// before anyone can use this product at all, was the one thing the product could not do. The engineering was
// well ahead of the operations, and this is the first piece of closing that gap.
//
// IT DOES NOT WEAKEN THE PROVISIONING DESIGN, and that distinction is the whole point. org creation stays a
// SECURITY DEFINER function that the APP ROLE CANNOT EXECUTE: missionmatch_app has no INSERT on organizations
// and no EXECUTE on provision_org, deliberately, so a compromised application session cannot mint a tenant.
// This handler reaches provision_org through the MIGRATOR POOL, the same BYPASSRLS seam curation_apply
// already uses for migrator owned tables, and that pool fails closed at boot unless it really is the migrator
// role. So the privilege boundary is unchanged; only the operator's path to it is.
package platform
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// Handler serves the operator plane. migrator may be nil, in which case provisioning reports itself
// unavailable rather than pretending: an operator console that silently does nothing is worse than one that
// says the migrator DSN is not configured on this node.
type Handler struct {
migrator *pgxpool.Pool
aud *audit.Writer
}
func NewHandler(migrator *pgxpool.Pool, aud *audit.Writer) *Handler {
return &Handler{migrator: migrator, aud: aud}
}
func (h *Handler) Routes(r chi.Router) {
r.Get("/platform/organizations", h.listOrganizations)
r.Post("/platform/organizations", h.createOrganization)
}
type createOrgRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
OwnerEmail string `json:"owner_email"`
OwnerName string `json:"owner_name"`
ExpiryHours int `json:"expiry_hours"`
}
// createOrgResponse carries the activation token EXACTLY ONCE. Only its hash is stored, so this response is
// the only moment the plaintext exists anywhere, and it is never logged, never audited, and never retrievable
// afterwards. An operator who loses it issues a new invite rather than reading the old one back.
type createOrgResponse struct {
OrganizationID string `json:"organization_id"`
OwnerUserID string `json:"owner_user_id"`
InviteID string `json:"invite_id"`
ActivationToken string `json:"activation_token"`
ExpiresAt string `json:"expires_at"`
RedeemAt string `json:"redeem_at"`
Note string `json:"note"`
}
const maxExpiryHours = 24 * 14
func (h *Handler) createOrganization(w http.ResponseWriter, r *http.Request) {
if h.migrator == nil {
writeErr(w, http.StatusServiceUnavailable, "provisioning is unavailable on this node because no migrator DSN is configured")
return
}
var in createOrgRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
in.Name = strings.TrimSpace(in.Name)
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
in.OwnerEmail = strings.ToLower(strings.TrimSpace(in.OwnerEmail))
in.OwnerName = strings.TrimSpace(in.OwnerName)
// Validated HERE rather than only at the database, so the operator gets a sentence instead of a constraint
// violation. The database still enforces all of it, because a check that lives only in a handler is a
// check a second caller skips.
switch {
case in.Name == "":
writeField(w, "name", "an organization name is required")
return
case in.Slug == "":
writeField(w, "slug", "a slug is required, it is the tenant's stable identifier")
return
case !validSlug(in.Slug):
writeField(w, "slug", "a slug may hold only lowercase letters, digits and hyphens, and must start with a letter")
return
case in.OwnerEmail == "" || !strings.Contains(in.OwnerEmail, "@"):
writeField(w, "owner_email", "a valid owner email is required, it becomes the first account")
return
case in.OwnerName == "":
writeField(w, "owner_name", "an owner name is required")
return
}
if in.ExpiryHours <= 0 {
in.ExpiryHours = 72
}
if in.ExpiryHours > maxExpiryHours {
writeField(w, "expiry_hours", "an activation link may not live longer than fourteen days")
return
}
// The token is generated by the SAME primitive the session layer uses, and only its HASH crosses into the
// database. cmd/provision-org does exactly this; sharing the primitive rather than the code keeps one
// definition of what a token is.
token, tokenHash, err := security.NewSessionToken()
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not generate an activation token")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var orgID, ownerID, inviteID uuid.UUID
err = h.migrator.QueryRow(ctx,
`SELECT organization_id, owner_user_id, invite_id FROM provision_org($1,$2,$3,$4,$5,$6)`,
in.Name, in.Slug, in.OwnerEmail, in.OwnerName, tokenHash, in.ExpiryHours*3600,
).Scan(&orgID, &ownerID, &inviteID)
if err != nil {
// A duplicate slug or email is an operator mistake, not a server fault, and deserves a 409 with the
// reason rather than a 500 that reads like an outage.
msg := err.Error()
if strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique constraint") {
writeErr(w, http.StatusConflict, "that slug or owner email is already taken")
return
}
writeErr(w, http.StatusInternalServerError, "provisioning failed")
return
}
// AUDITED AS A PLATFORM EVENT with a NULL org, because creating a tenant is not an action inside any
// tenant. THE TOKEN IS NOT IN THE AUDIT ROW. The audit records that an organization was created, by whom,
// and for which owner email; recording the activation token would put a live credential in an append only
// table that is deliberately impossible to delete from.
if h.aud != nil {
actor := actorID(r.Context())
body, _ := json.Marshal(map[string]any{
"organization_id": orgID.String(),
"slug": in.Slug,
"owner_user_id": ownerID.String(),
"owner_email": in.OwnerEmail,
"invite_id": inviteID.String(),
"expiry_hours": in.ExpiryHours,
})
_ = audit.OnPool(ctx, h.migrator, nil, audit.Event{
Action: "platform.organization.provisioned",
ActorUserID: actor,
EntityType: "organization",
EntityID: orgID.String(),
NewValues: body,
IPAddress: r.Header.Get("X-Forwarded-For"),
UserAgent: r.UserAgent(),
})
}
writeJSON(w, http.StatusCreated, createOrgResponse{
OrganizationID: orgID.String(),
OwnerUserID: ownerID.String(),
InviteID: inviteID.String(),
ActivationToken: token,
ExpiresAt: time.Now().UTC().Add(time.Duration(in.ExpiryHours) * time.Hour).Format(time.RFC3339),
RedeemAt: "POST /api/auth/invite/accept",
Note: "This token is shown ONCE and is not stored in plaintext anywhere. Deliver it to the owner, who " +
"redeems it with a password of their choosing. The first owner receives their product tools " +
"automatically. If the token is lost, provision a fresh invite rather than looking for this one.",
})
}
type orgRow struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
Users int `json:"users"`
CreatedAt string `json:"created_at"`
}
// listOrganizations is a CROSS TENANT read and is why this whole surface is operator gated. It returns counts
// and identifiers only: no candidate, no requirement, no document, nothing from inside a tenant. An operator
// needs to see that a tenant exists and how many accounts it has, not what its customers are working on.
func (h *Handler) listOrganizations(w http.ResponseWriter, r *http.Request) {
if h.migrator == nil {
writeErr(w, http.StatusServiceUnavailable, "the organization list is unavailable on this node because no migrator DSN is configured")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
rows, err := h.migrator.Query(ctx, `
SELECT o.id::text, o.name, o.slug, o.status,
(SELECT count(*) FROM users u WHERE u.organization_id = o.id)::int,
o.created_at
FROM organizations o
ORDER BY o.created_at DESC
LIMIT 500`)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not list organizations")
return
}
defer rows.Close()
out := make([]orgRow, 0, 64)
for rows.Next() {
var o orgRow
var created time.Time
if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Status, &o.Users, &created); err != nil {
writeErr(w, http.StatusInternalServerError, "could not read organizations")
return
}
o.CreatedAt = created.UTC().Format(time.RFC3339)
out = append(out, o)
}
if rows.Err() != nil {
writeErr(w, http.StatusInternalServerError, "could not read organizations")
return
}
writeJSON(w, http.StatusOK, map[string]any{"organizations": out, "count": len(out)})
}
func validSlug(s string) bool {
if len(s) < 2 || len(s) > 63 {
return false
}
if s[0] < 'a' || s[0] > 'z' {
return false
}
for _, c := range s {
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
return false
}
}
return !strings.Contains(s, "--")
}
func actorID(ctx context.Context) *uuid.UUID {
p, ok := tenant.UserFromContext(ctx)
if !ok {
return nil
}
id, err := uuid.Parse(p.UserID)
if err != nil {
return nil
}
return &id
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func writeField(w http.ResponseWriter, field, msg string) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": msg, "field": field})
}