774fdfcaa8
ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams
entry, service account) used to be two parallel, un-unified context
representations -- every authz predicate had to remember which one(s) it
needed, and every place that forgot either wrongly 403'd a service account
(terdut-server#23, terdut-operator#3), crashed on an unchecked zero-value
user id, or silently no-op'd. New internal/api/caller.go collapses both
into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount;
every existing predicate (userFromContext, callerTeamIDs, callerRole,
callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin,
requireTeamOwner, OperatorModeBlock) now reads through it, with identical
behavior for every untouched call site (alerts.go, incidents.go,
schedule.go, stats.go, etc.) -- confirmed by the full existing suite
passing unchanged.
Four real fixes land alongside the refactor, not just the restructuring:
1. callerMayManageServiceAccount gains the one load-bearing branch this
exists for: an instance-scoped service account may now manage (mint or
revoke a key on) any team-scoped account, not only a human admin, that
team's human owner, or the account itself. handleCreateServiceAccount
already let an instance-scoped caller *create* a team-scoped account for
any team; adopting or rotating one it didn't just create in the same
call -- terdut-operator's own documented crash-window recovery -- had no
equivalent permission and 403'd forever. Closes terdut-operator#3.
2. handleCreateInvite wrote a service-account caller's zero-value user id
straight into invites.created_by (nullable, but never passed as nil),
which foreign-key-violates against users(id) -- a 500, not success, for
any team-scoped service account minting an invite. Fixed the same way
handleCreateServiceAccount already handles the analogous case. Found
live while verifying this change, not filed separately since it's fixed
in the same place it was found.
3. handleMe and handleTestNotification 500'd for a service-account caller
(fetchUser/the ntfy_topic lookup against a zero-value user id that
matches no row); handleDismissOnboarding silently no-op'd (UPDATE ...
WHERE id = 0). All three now call Caller.AsHuman() and return an
explicit 403 ("this endpoint is for human accounts only").
4. Ratifies, rather than further narrows, two capabilities a team-scoped
service account already had by construction and this document's own
text once called "a gap acknowledged rather than closed": owner-equivalent
reach over membership/invites, and minting another service account for
its own team. terdut-operator's new TerdutTeam invite-minting feature is
about to depend on the first one, so this makes it documented, tested,
intentional behavior instead of an accident nobody was supposed to rely
on.
AdminOnly/requireSelfOrAdmin are unchanged in effect: still human-only,
forever, for every scope of service account -- confirmed by
TestAdminOnly_RefusesEveryServiceAccountScope. terdut-server#23's named
routes (POST /api/users, PUT /api/admin/settings) were never the right
thing to widen; its real fix is the terdut-operator invite feature,
recorded in SERVICE-ACCOUNTS.md's "What this unblocks" and closing that
issue once it ships.
SERVICE-ACCOUNTS.md amended in place (not a new file, its own established
convention) to describe the as-built Caller model, correct its own
aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged
as not matching shipped code, and record all of the above.
510 lines
16 KiB
Go
510 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// SettingSignupMode says who may create an account. It lives in the settings
|
|
// table with the other behaviour settings, so an administrator changes it in
|
|
// the admin page rather than in a chart.
|
|
//
|
|
// Two modes, not three. A domain-restricted mode was considered and dropped:
|
|
// with no email in this server there is nothing to verify an address against,
|
|
// so it would check the domain of a string somebody typed — a speed bump
|
|
// dressed as a control.
|
|
const (
|
|
SettingSignupMode = "signup_mode"
|
|
|
|
SignupInviteOnly = "invite_only"
|
|
SignupOpen = "open"
|
|
)
|
|
|
|
// defaultSignupMode is invite-only. An install that gets a public hostname
|
|
// before anybody has thought about sign-up should not be collecting accounts
|
|
// from the internet by default.
|
|
const defaultSignupMode = SignupInviteOnly
|
|
|
|
// inviteTTL is how long a new invite link lives. Long enough to send it and be
|
|
// read tomorrow, short enough that a link in an old chat log stops working.
|
|
const inviteTTL = 7 * 24 * time.Hour
|
|
|
|
// signupMode reads the current mode, falling back to invite-only for a missing
|
|
// or unrecognised value: the failure mode of a typo in this setting should be
|
|
// the closed door, not the open one.
|
|
func signupMode(ctx context.Context, db *sql.DB) string {
|
|
var raw string
|
|
if err := db.QueryRowContext(ctx,
|
|
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
|
|
return defaultSignupMode
|
|
}
|
|
if raw != SignupOpen && raw != SignupInviteOnly {
|
|
return defaultSignupMode
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// handleSignupInfo tells the sign-up page what it may offer, without requiring
|
|
// a session: whether open sign-up is on, and whether the invite in the URL is
|
|
// any good. A bad invite is better reported before somebody picks a password.
|
|
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
out := map[string]any{"mode": signupMode(r.Context(), db)}
|
|
|
|
if token := r.URL.Query().Get("invite"); token != "" {
|
|
inv, err := loadInvite(r.Context(), db, token)
|
|
switch {
|
|
case err == nil:
|
|
out["invite_valid"] = true
|
|
out["invite_team"] = inv.teamName
|
|
default:
|
|
// Deliberately one answer for expired, revoked, used up and
|
|
// never existed. Telling a stranger which it was tells them
|
|
// something about links they do not hold.
|
|
out["invite_valid"] = false
|
|
}
|
|
}
|
|
respond(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
type invite struct {
|
|
id int64
|
|
teamID int64
|
|
teamName string
|
|
role string
|
|
}
|
|
|
|
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
|
|
// it exists, has not been revoked, has not expired and has uses left.
|
|
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
|
|
var inv invite
|
|
err := q.QueryRowContext(ctx, `
|
|
SELECT i.id, i.team_id, t.name, i.role
|
|
FROM invites i
|
|
JOIN teams t ON t.id = i.team_id
|
|
WHERE i.token_hash = $1
|
|
AND i.revoked_at IS NULL
|
|
AND i.expires_at > `+nowEpoch+`
|
|
AND i.uses < i.max_uses`, hashToken(token)).
|
|
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return invite{}, errInviteUnusable
|
|
}
|
|
return inv, err
|
|
}
|
|
|
|
var errInviteUnusable = errors.New("invite is not usable")
|
|
|
|
// handleSignup creates an account, and puts it somewhere.
|
|
//
|
|
// Rate-limited on the same limiter as login, by address: sign-up is the other
|
|
// unauthenticated endpoint that writes, and an open install without this is a
|
|
// way to fill somebody's user table.
|
|
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
addr := clientAddr(r)
|
|
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
|
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Invite string `json:"invite"`
|
|
TeamName string `json:"team_name"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
req.Username = strings.TrimSpace(req.Username)
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
req.TeamName = strings.TrimSpace(req.TeamName)
|
|
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
if msg := validatePassword(req.Password); msg != "" {
|
|
respond(w, http.StatusBadRequest, errResp(msg))
|
|
return
|
|
}
|
|
|
|
mode := signupMode(r.Context(), db)
|
|
var inv invite
|
|
hasInvite := false
|
|
if req.Invite != "" {
|
|
var err error
|
|
inv, err = loadInvite(r.Context(), db, req.Invite)
|
|
if err != nil {
|
|
limiter.fail("signup:" + addr)
|
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
|
return
|
|
}
|
|
hasInvite = true
|
|
}
|
|
if !hasInvite && mode != SignupOpen {
|
|
// No invite and the door is shut. Not 404: the endpoint exists and
|
|
// saying so is how somebody knows to ask for a link.
|
|
respond(w, http.StatusForbidden,
|
|
errResp("sign-up is invite-only on this server"))
|
|
return
|
|
}
|
|
if !hasInvite && req.TeamName == "" {
|
|
// Open sign-up with no team would create an account that sees an
|
|
// empty queue and can be paged by nobody.
|
|
respond(w, http.StatusBadRequest, errResp("team_name is required"))
|
|
return
|
|
}
|
|
|
|
hash, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
var userID int64
|
|
var invitedVia *int64
|
|
if hasInvite {
|
|
invitedVia = &inv.id
|
|
}
|
|
if err := tx.QueryRowContext(r.Context(), `
|
|
INSERT INTO users (username, email, password_hash, invited_via)
|
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
|
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
|
return
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
teamID, role := inv.teamID, inv.role
|
|
if !hasInvite {
|
|
// Open sign-up makes a team, and its creator owns it.
|
|
if err := tx.QueryRowContext(r.Context(),
|
|
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
|
return
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
role = models.RoleOwner
|
|
}
|
|
|
|
if _, err := tx.ExecContext(r.Context(),
|
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
|
teamID, userID, role); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
if hasInvite {
|
|
// Counted inside the transaction, so two people redeeming the last
|
|
// use of a link at once cannot both get in.
|
|
res, err := tx.ExecContext(r.Context(),
|
|
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
// Signed in immediately: the alternative is a form that says "now go
|
|
// and log in", which is the same credential typed twice.
|
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
user, _ := fetchUser(r.Context(), db, userID)
|
|
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
|
|
}
|
|
}
|
|
|
|
// maxSignupsPerAddr is looser than the login limit: several people joining from
|
|
// one office share an address, and the thing being limited is account creation
|
|
// rather than password guessing.
|
|
const maxSignupsPerAddr = 10
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Invites
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type inviteJSON struct {
|
|
ID int64 `json:"id"`
|
|
TeamID int64 `json:"team_id"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
MaxUses int64 `json:"max_uses"`
|
|
Uses int64 `json:"uses"`
|
|
Revoked bool `json:"revoked"`
|
|
|
|
// URL is the whole link, returned once when the invite is created. Like an
|
|
// integration key, only its hash is stored.
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
func handleListInvites(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
|
|
FROM invites
|
|
WHERE team_id = $1
|
|
ORDER BY id DESC`, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []inviteJSON{}
|
|
for rows.Next() {
|
|
var i inviteJSON
|
|
var created, expires int64
|
|
var revoked *int64
|
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
|
|
&i.MaxUses, &i.Uses, &revoked); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
|
i.Revoked = revoked != nil
|
|
out = append(out, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
|
|
// a team's configuration: deciding who joins is configuring the team.
|
|
func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Role string `json:"role"`
|
|
MaxUses int64 `json:"max_uses"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Role == "" {
|
|
req.Role = models.RoleMember
|
|
}
|
|
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
|
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
|
return
|
|
}
|
|
if req.MaxUses == 0 {
|
|
req.MaxUses = 1
|
|
}
|
|
if req.MaxUses < 1 || req.MaxUses > 100 {
|
|
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
// created_by is nullable (ON DELETE SET NULL) for exactly this
|
|
// reason: the caller minting an invite is not always a human with a
|
|
// real users row. A team-scoped service account is owner-equivalent
|
|
// here (requireTeamOwner above already let it through), and this
|
|
// must leave created_by NULL for one the same way
|
|
// handleCreateServiceAccount already does for the analogous case —
|
|
// an unchecked zero value would violate the users(id) foreign key
|
|
// instead of recording "nobody" cleanly.
|
|
var createdBy *int64
|
|
if u, ok := userFromContext(r.Context()); ok {
|
|
id := u.ID
|
|
createdBy = &id
|
|
}
|
|
expires := time.Now().Add(inviteTTL)
|
|
|
|
var out inviteJSON
|
|
var created, expiresAt int64
|
|
if err := db.QueryRowContext(r.Context(), `
|
|
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
|
hash, teamID, req.Role, createdBy, expires.Unix(), req.MaxUses).
|
|
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
out.CreatedAt = time.Unix(created, 0).UTC()
|
|
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
|
|
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
|
|
respond(w, http.StatusCreated, out)
|
|
}
|
|
}
|
|
|
|
// handleRevokeInvite stops a link working without waiting for it to expire.
|
|
func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE invites SET revoked_at = "+nowEpoch+
|
|
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Onboarding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// handleTestNotification publishes one push to the caller's own topic.
|
|
//
|
|
// The point of the first-run checklist's notification step is not that a topic
|
|
// string has been typed but that a phone buzzes, and only the person holding it
|
|
// can tell whether it did. Published directly rather than through the outbox:
|
|
// the outbox row requires an incident, and this deliberately belongs to no
|
|
// incident.
|
|
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if cfg.BaseURL == "" {
|
|
respond(w, http.StatusServiceUnavailable,
|
|
errResp("this server has no ntfy configured, so it can send nothing"))
|
|
return
|
|
}
|
|
caller, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
|
return
|
|
}
|
|
|
|
var topic *string
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if topic == nil || *topic == "" {
|
|
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
|
|
return
|
|
}
|
|
|
|
if err := publish(r.Context(), cfg, ntfyMessage{
|
|
Topic: *topic,
|
|
Title: "terdut test",
|
|
Message: "If this arrived, your notifications work.",
|
|
Tags: []string{"white_check_mark"},
|
|
}); err != nil {
|
|
// The failure is the useful part here: a wrong topic, a token the
|
|
// ntfy server rejects, or an ntfy that is down all look the same
|
|
// from the phone, which is silence.
|
|
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleDismissOnboarding hides the first-run checklist, or brings it back.
|
|
// Stored per user rather than in the browser: somebody who finishes setting up
|
|
// on a laptop should not be nagged again on their phone.
|
|
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Dismissed *bool `json:"dismissed"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
|
|
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
|
|
return
|
|
}
|
|
caller, ok := userFromContext(r.Context())
|
|
if !ok {
|
|
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
|
return
|
|
}
|
|
|
|
var err error
|
|
if *req.Dismissed {
|
|
_, err = db.ExecContext(r.Context(),
|
|
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
|
|
} else {
|
|
_, err = db.ExecContext(r.Context(),
|
|
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|