A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.
Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.
Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.
Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.
Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.
Found by running it, not by tests:
* UPDATE ... RETURNING gives the value AFTER the update, so the prune
read back empty keys, deleted nothing, and the table grew with
footfall exactly as if it were not there. The fake agreed with either
version; only the live Postgres test caught it.
* Trusting only the auth flag broke every shop card, because Sites.jsx
rebuilt a partial snapshot object and dropped it. A relative URL is
now sufficient on its own.
* ago() renders a future time as "just now", so a code valid for a week
read "expires just now".
Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
391 lines
13 KiB
Go
391 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// Adding people to a company: invitations, registration, and the team list.
|
|
//
|
|
// Registration is by INVITATION, and that is the same decision handlers_admin.go
|
|
// records for creating a company: an endpoint a stranger can call to create an
|
|
// account is a far larger thing to secure than one reachable only through
|
|
// somebody who already has one. What was missing was not the openness - it was
|
|
// that a tenant could not add a SECOND person at all except by somebody with a
|
|
// shell on the server running `provision user`. A shop with an owner and four
|
|
// staff either shared one password between five people or raised a ticket per
|
|
// person, and a phone app for shop-floor staff could not exist while there was
|
|
// only ever one account to sign in as.
|
|
//
|
|
// So: a manager mints a code, hands it over, and the holder chooses their own
|
|
// password. The code carries the address and the role; the request carries only
|
|
// the password and a name. That split is load-bearing and is why this is not
|
|
// simply "create a user with these fields" - see handleRegister.
|
|
|
|
const (
|
|
// Long enough to reach somebody who is not at work today, short enough that
|
|
// a code left in a chat thread is worthless before anyone scrolls back to
|
|
// it. An expired invitation costs one click to reissue.
|
|
invitationTTL = 7 * 24 * time.Hour
|
|
maxInvitation = 30 * 24 * time.Hour
|
|
)
|
|
|
|
// handleInvite mints one invitation.
|
|
//
|
|
// Manager and above. Not staff: the holder of a code gets an account inside
|
|
// this company, so it is a credential, not a convenience.
|
|
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
if !p.CanManageSites() || p.ClientID == "" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Your account cannot invite people to this company.")
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
FullName string `json:"full_name"`
|
|
Role string `json:"role"`
|
|
Days int `json:"expires_in_days"`
|
|
}
|
|
if err := decode(w, r, &body); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
email := auth.NormalizeEmail(body.Email)
|
|
if email == "" || !strings.Contains(email, "@") {
|
|
badRequest(w, "an email address is required - it is what they will sign in with")
|
|
return
|
|
}
|
|
role := strings.ToLower(trim(body.Role))
|
|
if role == "" {
|
|
role = "staff"
|
|
}
|
|
// 'admin' is absent on purpose. A platform administrator is defined by
|
|
// having no company at all, so an invitation could never mint a real one -
|
|
// what it could do is create the tenant-scoped row with role='admin' that
|
|
// adminOnly exists to reject, and a role nothing can use is a trap rather
|
|
// than a feature.
|
|
switch role {
|
|
case "owner", "manager", "staff":
|
|
default:
|
|
badRequest(w, "role must be owner, manager or staff")
|
|
return
|
|
}
|
|
// Only an owner may create another owner. A manager promoting somebody past
|
|
// themselves is an escalation, and it is the one shape of this endpoint
|
|
// that would matter if a manager account were ever taken over.
|
|
if role == "owner" && p.Role != "owner" && p.Role != "admin" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Only an owner can invite another owner.")
|
|
return
|
|
}
|
|
|
|
ttl := invitationTTL
|
|
if body.Days > 0 {
|
|
ttl = time.Duration(body.Days) * 24 * time.Hour
|
|
if ttl > maxInvitation {
|
|
ttl = maxInvitation
|
|
}
|
|
}
|
|
|
|
code, err := auth.NewEnrolmentCode()
|
|
if err != nil {
|
|
s.serverError(w, "mint invitation", err)
|
|
return
|
|
}
|
|
inv, err := s.Store.CreateInvitation(r.Context(), NewInvitation{
|
|
ClientID: p.ClientID,
|
|
Email: email,
|
|
FullName: clip(trim(body.FullName), 200),
|
|
Role: role,
|
|
CodeHash: auth.HashToken(auth.NormalizeCode(code)),
|
|
InvitedBy: p.UserID,
|
|
ExpiresAt: s.now().Add(ttl),
|
|
})
|
|
if err != nil {
|
|
s.serverError(w, "create invitation", err)
|
|
return
|
|
}
|
|
// The plaintext exists here and in this response, and nowhere else. Like
|
|
// every other secret this system mints, it is shown once: one a support
|
|
// engineer can look up later is one anybody with support access can redeem.
|
|
inv.Code = code
|
|
|
|
s.Store.Audit(r.Context(), AuditEntry{
|
|
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
|
Action: "team.invite", Entity: "invitation", EntityID: inv.ID,
|
|
Detail: map[string]any{"email": email, "role": role},
|
|
})
|
|
writeJSON(w, http.StatusCreated, inv)
|
|
}
|
|
|
|
func (s *Server) handleInvitations(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
if !p.CanManageSites() || p.ClientID == "" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Your account cannot see this company's invitations.")
|
|
return
|
|
}
|
|
rows, err := s.Store.PendingInvitations(r.Context(), p.ClientID)
|
|
if err != nil {
|
|
s.serverError(w, "list invitations", err)
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []Invitation{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
func (s *Server) handleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
if !p.CanManageSites() || p.ClientID == "" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Your account cannot withdraw invitations.")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if !looksLikeUUID(id) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such invitation.")
|
|
return
|
|
}
|
|
if err := s.Store.RevokeInvitation(r.Context(), p.ClientID, id); err != nil {
|
|
// Already used or already withdrawn. Reported rather than swallowed:
|
|
// "I cancelled it" and "somebody had already joined with it" need
|
|
// opposite next steps from whoever pressed the button.
|
|
writeErr(w, http.StatusNotFound, "not_found",
|
|
"That invitation is no longer pending.")
|
|
return
|
|
}
|
|
s.Store.Audit(r.Context(), AuditEntry{
|
|
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
|
Action: "team.invite.revoke", Entity: "invitation", EntityID: id,
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleInvitationPreview lets a client show what a code is for before asking
|
|
// somebody to choose a password.
|
|
//
|
|
// Unauthenticated, because the holder has no account yet - that is the whole
|
|
// point - and it discloses only what the code itself already asserts: the
|
|
// company, the address it was issued for, and the role. Unknown, expired, spent
|
|
// and revoked are one identical answer, exactly as enrolment already does:
|
|
// telling them apart only helps somebody guessing codes, and the holder's next
|
|
// step is the same in all four cases.
|
|
func (s *Server) handleInvitationPreview(w http.ResponseWriter, r *http.Request) {
|
|
code := auth.NormalizeCode(r.URL.Query().Get("code"))
|
|
if code == "" {
|
|
badRequest(w, "a code is required")
|
|
return
|
|
}
|
|
prev, err := s.Store.InvitationByCode(r.Context(), auth.HashToken(code))
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "invalid_code",
|
|
"That invitation code is not valid. Ask for a new one.")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, prev)
|
|
}
|
|
|
|
// handleRegister turns a code into an account and signs the person in.
|
|
//
|
|
// Unauthenticated for the same reason `POST /api/agent/enrol` is: whoever is
|
|
// doing this has no account yet, and requiring one first would mean shipping a
|
|
// password to everybody who needs one.
|
|
//
|
|
// The email and the role come from the INVITATION, never from this body. A code
|
|
// forwarded to a colleague must not become an account for them, and a staff
|
|
// invitation must not be redeemed as an owner - which is exactly what a
|
|
// caller-supplied role would allow. The only things the request decides are the
|
|
// password and the display name.
|
|
//
|
|
// It returns a Session, identical in shape to login. A new member's next screen
|
|
// is the app, not a sign-in form they have to fill in with the password they
|
|
// chose four seconds ago.
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
FullName string `json:"full_name"`
|
|
Password string `json:"password"`
|
|
Device string `json:"device"`
|
|
}
|
|
if err := decode(w, r, &body); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
code := auth.NormalizeCode(body.Code)
|
|
if code == "" {
|
|
badRequest(w, "an invitation code is required")
|
|
return
|
|
}
|
|
|
|
// Throttled on the code, by IP. Redeeming is the one unauthenticated write
|
|
// in this package that creates a row, so an unbounded one is a way to grind
|
|
// through the code space and to fill a table while doing it.
|
|
_, perIP := s.throttles()
|
|
ipKey := clientIP(r)
|
|
if !perIP.Allow(ipKey) {
|
|
writeErr(w, http.StatusTooManyRequests, "too_many_attempts",
|
|
"Too many attempts. Wait a few minutes and try again.")
|
|
return
|
|
}
|
|
|
|
if err := auth.CheckPasswordPolicy(body.Password); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
hash, err := auth.HashPassword(body.Password)
|
|
if err != nil {
|
|
s.serverError(w, "hash password", err)
|
|
return
|
|
}
|
|
|
|
rec, err := s.Store.RedeemInvitation(r.Context(), auth.HashToken(code),
|
|
clip(trim(body.FullName), 200), hash)
|
|
if err != nil {
|
|
perIP.Fail(ipKey)
|
|
if msg, ok := conflictMessage(err); ok {
|
|
// The address already has an account somewhere on the platform.
|
|
// Worth saying plainly: the fix is to sign in, not to try again.
|
|
writeErr(w, http.StatusConflict, "conflict", msg)
|
|
return
|
|
}
|
|
writeErr(w, http.StatusNotFound, "invalid_code",
|
|
"That invitation code is not valid. Ask for a new one.")
|
|
return
|
|
}
|
|
perIP.Reset(ipKey)
|
|
|
|
sess, err := s.mint(r, rec, body.Device)
|
|
if err != nil {
|
|
// The account exists and the invitation is spent. Say so rather than
|
|
// implying nothing happened - the recovery is to sign in, and telling
|
|
// them to redeem again would fail forever.
|
|
s.logf("ERROR register: created %s but could not start a session: %v", rec.ID, err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error",
|
|
"Your account was created but we could not sign you in. Please sign in.")
|
|
return
|
|
}
|
|
s.Store.Audit(r.Context(), AuditEntry{
|
|
ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user",
|
|
Action: "team.register", Entity: "user", EntityID: rec.ID,
|
|
Detail: map[string]any{"role": rec.Role, "device": trim(body.Device)},
|
|
})
|
|
s.logf("registered %s (%s) into client %s", rec.Email, rec.Role, rec.ClientID)
|
|
writeJSON(w, http.StatusCreated, sess)
|
|
}
|
|
|
|
func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
if p.ClientID == "" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"This account does not belong to a company.")
|
|
return
|
|
}
|
|
rows, err := s.Store.Team(r.Context(), p.ClientID)
|
|
if err != nil {
|
|
s.serverError(w, "list team", err)
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []TeamMember{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// handleUpdateTeamMember changes a role, or turns an account off.
|
|
//
|
|
// Deactivating is the "they have left" button, and the store revokes their
|
|
// sessions in the same transaction: an access token lives twelve hours, so
|
|
// without that, removing somebody's access would remove it sometime tomorrow.
|
|
func (s *Server) handleUpdateTeamMember(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
if !p.CanManageSites() || p.ClientID == "" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Your account cannot change who works here.")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if !looksLikeUUID(id) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
|
|
return
|
|
}
|
|
|
|
var up TeamUpdate
|
|
if err := decode(w, r, &up); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
if up.Role == nil && up.Active == nil {
|
|
badRequest(w, "nothing to change - send a role, an active flag, or both")
|
|
return
|
|
}
|
|
if up.Role != nil {
|
|
role := strings.ToLower(trim(*up.Role))
|
|
switch role {
|
|
case "owner", "manager", "staff":
|
|
default:
|
|
badRequest(w, "role must be owner, manager or staff")
|
|
return
|
|
}
|
|
if role == "owner" && p.Role != "owner" && p.Role != "admin" {
|
|
writeErr(w, http.StatusForbidden, "forbidden",
|
|
"Only an owner can make somebody else an owner.")
|
|
return
|
|
}
|
|
up.Role = &role
|
|
}
|
|
|
|
// The company must keep an owner. Losing the last one leaves a tenant
|
|
// nobody can administer, and the only way back is a shell on the server -
|
|
// which is the thing this whole surface exists to stop needing.
|
|
demoting := up.Role != nil && *up.Role != "owner"
|
|
disabling := up.Active != nil && !*up.Active
|
|
if demoting || disabling {
|
|
if last, err := s.lastOwner(r, id); err != nil {
|
|
s.serverError(w, "count owners", err)
|
|
return
|
|
} else if last {
|
|
writeErr(w, http.StatusConflict, "last_owner",
|
|
"This is the company's only owner. Make somebody else an owner first.")
|
|
return
|
|
}
|
|
}
|
|
|
|
m, err := s.Store.UpdateTeamMember(r.Context(), p.ClientID, id, up)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
|
|
return
|
|
}
|
|
s.Store.Audit(r.Context(), AuditEntry{
|
|
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
|
Action: "team.update", Entity: "user", EntityID: id,
|
|
Detail: map[string]any{"role": m.Role, "active": m.Active},
|
|
})
|
|
writeJSON(w, http.StatusOK, m)
|
|
}
|
|
|
|
// lastOwner reports whether the named member is the only active owner left.
|
|
func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
|
|
p := PrincipalFrom(r.Context())
|
|
rows, err := s.Store.Team(r.Context(), p.ClientID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
owners, isOwner := 0, false
|
|
for _, m := range rows {
|
|
if m.Role == "owner" && m.Active {
|
|
owners++
|
|
if m.ID == userID {
|
|
isOwner = true
|
|
}
|
|
}
|
|
}
|
|
return isOwner && owners == 1, nil
|
|
}
|