Accounts people can create, and photos on a server with no bucket
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
This commit is contained in:
@@ -42,6 +42,27 @@ type Store interface {
|
||||
SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
RotateSession(ctx context.Context, sessionID string, s NewSession) error
|
||||
RevokeSession(ctx context.Context, sessionID string) error
|
||||
// Which devices are signed in, and signing one of them out. This is what
|
||||
// an opaque-token session table buys over a JWT, and until these existed
|
||||
// the product paid the cost of that choice without the benefit.
|
||||
UserSessions(ctx context.Context, userID string) ([]DeviceSession, error)
|
||||
RevokeUserSession(ctx context.Context, userID, sessionID string) error
|
||||
RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error)
|
||||
|
||||
// --- team and invitations ---
|
||||
// Registration is by invitation: the code carries the address and the role
|
||||
// so neither can be chosen by whoever redeems it.
|
||||
CreateInvitation(ctx context.Context, in NewInvitation) (Invitation, error)
|
||||
PendingInvitations(ctx context.Context, clientID string) ([]Invitation, error)
|
||||
RevokeInvitation(ctx context.Context, clientID, id string) error
|
||||
InvitationByCode(ctx context.Context, hash []byte) (InvitationPreview, error)
|
||||
// RedeemInvitation spends the code and creates the account in ONE
|
||||
// transaction: a spent invitation with no user behind it is unusable, and a
|
||||
// user with the invitation still open is a second account waiting for
|
||||
// whoever else was forwarded the code.
|
||||
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
|
||||
Team(ctx context.Context, clientID string) ([]TeamMember, error)
|
||||
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
|
||||
|
||||
// --- reports ---
|
||||
Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error)
|
||||
@@ -98,6 +119,11 @@ type Store interface {
|
||||
AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error)
|
||||
|
||||
// --- images ---
|
||||
// Face images held by this server, for a deployment with no object
|
||||
// storage. Where a bucket is configured none of these three is called.
|
||||
PutVisitFace(ctx context.Context, clientID, siteID string, jpeg []byte) (string, error)
|
||||
VisitFace(ctx context.Context, clientID, key string) ([]byte, error)
|
||||
DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error
|
||||
VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error)
|
||||
VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error)
|
||||
ForgetVisitor(ctx context.Context, clientID, visitorID string) error
|
||||
@@ -196,6 +222,26 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout))
|
||||
mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe))
|
||||
// Registration. Unauthenticated for the same reason agent enrolment is:
|
||||
// whoever is doing this has no account yet, and requiring one first would
|
||||
// mean shipping a password to everybody who needs one.
|
||||
mux.HandleFunc("GET /api/auth/invitation", s.handleInvitationPreview)
|
||||
mux.HandleFunc("POST /api/auth/register", s.handleRegister)
|
||||
// Devices. A person may list and revoke their own sessions; removing a
|
||||
// colleague's access is a different question, answered by deactivating them
|
||||
// on the team endpoint below.
|
||||
mux.HandleFunc("GET /api/auth/sessions", s.authed(s.handleSessions))
|
||||
mux.HandleFunc("DELETE /api/auth/sessions/{id}", s.authed(s.handleRevokeSession))
|
||||
mux.HandleFunc("POST /api/auth/sessions/revoke-others",
|
||||
s.authed(s.handleRevokeOtherSessions))
|
||||
|
||||
// --- the people who work here ---
|
||||
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
|
||||
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
|
||||
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
|
||||
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
|
||||
mux.HandleFunc("DELETE /api/team/invitations/{id}",
|
||||
s.authed(s.handleRevokeInvitation))
|
||||
|
||||
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
||||
@@ -250,6 +296,8 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
|
||||
// Authenticated by the agent's own API token, not a user session.
|
||||
mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL))
|
||||
// The fallback the agent takes when upload-url answers images_disabled.
|
||||
mux.HandleFunc("POST /api/agent/faces", s.agentAuthed(s.handlePutFace))
|
||||
// What this shop PC should be running, and what it reports back.
|
||||
mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras))
|
||||
mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport))
|
||||
@@ -262,6 +310,11 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
|
||||
|
||||
mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage))
|
||||
// The bytes of a face this server holds itself. Session-authenticated
|
||||
// rather than a signed link: there is no third party to delegate to, and an
|
||||
// unauthenticated URL would be a way to reach a customer's photograph with
|
||||
// no session at all.
|
||||
mux.HandleFunc("GET /api/faces/{id}", s.authed(s.handleGetFace))
|
||||
// The erasure path. Destroys the template and the photo; keeps the
|
||||
// anonymous visit counts, which are legitimate aggregate data.
|
||||
mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor))
|
||||
|
||||
@@ -200,6 +200,11 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
// No key, because that is what this deployment actually produces: the
|
||||
// engine's `app.store_faces` is off, so no crop is ever captured and no
|
||||
// key is ever written. A bucket key on a server with no bucket is a
|
||||
// different state entirely and gets its own sentence below.
|
||||
fs.arrivals[0].ImageKey = ""
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
@@ -212,6 +217,54 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Three absences now, not two: face images may live in a bucket OR in this
|
||||
// database, so "there is no bucket" stopped being a synonym for "there are
|
||||
// no photos" the moment the fallback existed.
|
||||
t.Run("a bucket key on a server that has lost its bucket", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1) // seeded with an object-store key
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if got.Available {
|
||||
t.Fatalf("nothing can be served without the bucket, got %+v", got)
|
||||
}
|
||||
// Deliberately NOT "we store no photos". The photo exists and this
|
||||
// server can no longer reach it, which is a configuration fault
|
||||
// somebody can fix - and reporting it as an ordinary empty record is
|
||||
// how it would go unnoticed for a year.
|
||||
if !strings.Contains(got.Reason, "no longer reach") {
|
||||
t.Errorf("want a configuration reason, got %q", got.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a face this server holds itself", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil // no object storage anywhere
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
fs.arrivals[0].ImageKey = "db:00000000-0000-4000-b000-000000000001"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if !got.Available {
|
||||
t.Fatalf("a stored face should be offered, got %+v", got)
|
||||
}
|
||||
// Auth is what tells a client this URL needs the session bearer. A
|
||||
// browser <img> cannot load it and a mobile image view can, and there
|
||||
// is nothing in the URL itself that says so.
|
||||
if !got.Auth {
|
||||
t.Error("a face held by this server must be marked as needing auth")
|
||||
}
|
||||
if strings.Contains(got.URL, "db:") {
|
||||
t.Errorf("the storage key leaked into the URL: %q", got.URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this visit simply had none", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
|
||||
230
server/internal/api/faces_test.go
Normal file
230
server/internal/api/faces_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Face images held by this server, for a deployment with no object storage.
|
||||
//
|
||||
// The property under test throughout is that the two storage routes differ in
|
||||
// exactly one hop: the key is minted differently and everything downstream -
|
||||
// ingest, the feed, the customer record, erasure - is one implementation.
|
||||
|
||||
func putFace(t *testing.T, srv *Server, token string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agent/faces", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestAnAgentStoresAFaceAndAPersonReadsItBack(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil // no object storage anywhere: the case this exists for
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
img := jpegBytes(512)
|
||||
rr := putFace(t, srv, "agent-token", img)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("upload: %d %s", rr.Code, rr.Body)
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A prefixed key, so `visits.image_key` can name an object in either store
|
||||
// and the read path can tell which without a second lookup.
|
||||
if !strings.HasPrefix(out.Key, "db:") {
|
||||
t.Fatalf("want a db: key, got %q", out.Key)
|
||||
}
|
||||
// The tenant and the site come from the AGENT's credential, never the
|
||||
// request, so a shop PC cannot file an image under another company.
|
||||
if fs.lastFaceClient != "client-acme" || fs.lastFaceSite != "site-1" {
|
||||
t.Fatalf("stored against %s/%s", fs.lastFaceClient, fs.lastFaceSite)
|
||||
}
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "GET", faceURL(out.Key), sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read back: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Errorf("content type %q", got)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), img) {
|
||||
t.Error("the bytes that came back are not the ones that went in")
|
||||
}
|
||||
}
|
||||
|
||||
// This endpoint stores what it is handed and serves it back to a browser, so
|
||||
// the one thing it must not become is a way to park arbitrary content under a
|
||||
// URL this server will serve. Checked against the bytes, never the header.
|
||||
func TestOnlyAJPEGIsStoredAsAFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
|
||||
for _, body := range []string{
|
||||
"<html><script>alert(1)</script></html>",
|
||||
"GIF89a",
|
||||
"%PDF-1.4",
|
||||
"",
|
||||
} {
|
||||
rr := putFace(t, srv, "agent-token", []byte(body))
|
||||
if rr.Code == http.StatusCreated {
|
||||
t.Errorf("accepted %q as a face image", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFaceIsNotReadableWithoutASession(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
|
||||
// The reason it is session-authenticated rather than a signed link: there
|
||||
// is no third party to delegate to, and an unauthenticated URL would be a
|
||||
// way to reach a customer's photograph with no session at all.
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a face was served with no session, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantCannotReadYourStoredFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("acme-agent", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
|
||||
rr := putFace(t, srv, "acme-agent", jpegBytes(64))
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
|
||||
// An image key travels in API responses, so a caller who kept one - or
|
||||
// guessed one - must get nothing rather than somebody else's customer.
|
||||
beta := login(t, srv, "other@beta.com", "correct horse battery")
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), beta.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("another tenant read a stored face, got %d", rec.Code)
|
||||
}
|
||||
acme := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), acme.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the owning tenant could not read its own face, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The customer record has to work on a deployment with no bucket too - it is
|
||||
// the screen staff use to recognise the person in front of them.
|
||||
func TestTheCustomerPhotoWorksWithNoObjectStorage(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var up struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &up)
|
||||
const visitor = "44444444-4444-4444-8444-444444444444"
|
||||
fs.imageKeys[visitor] = up.Key
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "GET", "/api/visitors/"+visitor+"/image", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("customer photo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var img Image
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !img.Available || !img.Auth {
|
||||
t.Fatalf("want an available image that needs the session, got %+v", img)
|
||||
}
|
||||
// The storage key names a tenant's prefix and must never be what a client
|
||||
// receives, on either route.
|
||||
if strings.Contains(rec.Body.String(), "db:") {
|
||||
t.Errorf("the storage key leaked: %s", rec.Body.String())
|
||||
}
|
||||
// Reading a face is worth an audit row wherever the LINK is handed out.
|
||||
// Recorded here rather than at the byte fetch, because the bucket route's
|
||||
// bytes never touch this server and the two must be counted the same way.
|
||||
if !audited(fs, "image.view") {
|
||||
t.Error("reading a customer photo left no audit row")
|
||||
}
|
||||
}
|
||||
|
||||
func audited(fs *fakeStore, action string) bool {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Erasure has to destroy an image this server holds, not only one in a bucket.
|
||||
// A face image that survives an erasure request is the one outcome that
|
||||
// endpoint must never produce.
|
||||
func TestErasureDestroysAStoredFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var up struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &up)
|
||||
const visitor = "55555555-5555-4555-8555-555555555555"
|
||||
fs.imageKeys[visitor] = up.Key
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("erase: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(t, srv, "GET", faceURL(up.Key), sess.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("the face survived erasure, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// If the image cannot be destroyed, NOTHING is erased and the caller is told.
|
||||
// Reporting a legal request as honoured when it was not is the failure this
|
||||
// path exists to prevent.
|
||||
func TestAFailedFaceDeleteAbortsTheWholeErasure(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
seedUser(fs)
|
||||
const visitor = "66666666-6666-4666-8666-666666666666"
|
||||
fs.imageKeys[visitor] = "db:66666666-6666-4666-8666-666666666666"
|
||||
fs.faceDeleteErr = errors.New("storage is down")
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("want 502 and nothing erased, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.forgotten) != 0 {
|
||||
t.Fatalf("the record was erased even though the photo could not be: %v", fs.forgotten)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -70,6 +71,14 @@ type fakeStore struct {
|
||||
lastCheckKind string
|
||||
lastCheckSeconds int
|
||||
|
||||
// Invitations, and the faces this server holds itself.
|
||||
invites map[string]*fakeInvite // by code hash hex
|
||||
faces map[string][]byte // "client/id"
|
||||
lastFaceClient string
|
||||
lastFaceSite string
|
||||
deletedFaces []string
|
||||
faceDeleteErr error
|
||||
|
||||
cameras []Camera
|
||||
agentCameras []AgentCamera
|
||||
lastCameraReport AgentCameraReport
|
||||
@@ -97,6 +106,7 @@ type fakeSession struct {
|
||||
id string
|
||||
p auth.Principal
|
||||
accessExp, refreshExp time.Time
|
||||
device string
|
||||
revoked bool
|
||||
}
|
||||
|
||||
@@ -150,7 +160,11 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.nextID++
|
||||
id := "sess-" + itoa(f.nextID)
|
||||
// uuid-SHAPED, because the handlers validate the shape of an id before
|
||||
// spending a database round trip on it. A fake that mints "sess-1" would
|
||||
// make every id-addressed session route 404 in tests and pass in
|
||||
// production, which is the wrong way round.
|
||||
id := fmt.Sprintf("00000000-0000-4000-8000-%012d", f.nextID)
|
||||
var rec UserRecord
|
||||
for _, u := range f.users {
|
||||
if u.ID == n.UserID {
|
||||
@@ -165,6 +179,7 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
FullName: rec.FullName, Role: rec.Role,
|
||||
},
|
||||
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
||||
device: n.Device,
|
||||
}
|
||||
f.sessions[id] = s
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
@@ -673,3 +688,228 @@ func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
|
||||
}
|
||||
|
||||
type cameraRef struct{ client, site, engineID string }
|
||||
|
||||
// ==================================== team, invitations, sessions, faces ====
|
||||
//
|
||||
// These behave rather than merely satisfy the interface: single use, tenant
|
||||
// scoping and "the role comes from the invitation" are the properties the
|
||||
// handlers are trusted for, so a fake that always says yes would make the tests
|
||||
// that check them meaningless.
|
||||
|
||||
type fakeInvite struct {
|
||||
id, clientID, email, fullName, role string
|
||||
expires time.Time
|
||||
used, revoked bool
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.invites == nil {
|
||||
f.invites = map[string]*fakeInvite{}
|
||||
}
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID)
|
||||
f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{
|
||||
id: id, clientID: in.ClientID, email: in.Email,
|
||||
fullName: in.FullName, role: in.Role, expires: in.ExpiresAt,
|
||||
}
|
||||
return Invitation{
|
||||
ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role,
|
||||
ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Invitation
|
||||
for _, v := range f.invites {
|
||||
if v.clientID != clientID || v.used || v.revoked {
|
||||
continue
|
||||
}
|
||||
out = append(out, Invitation{ID: v.id, Email: v.email,
|
||||
FullName: v.fullName, Role: v.role,
|
||||
ExpiresAt: v.expires.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, v := range f.invites {
|
||||
if v.id == id && v.clientID == clientID && !v.used && !v.revoked {
|
||||
v.revoked = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no such pending invitation")
|
||||
}
|
||||
|
||||
func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.invites[hex.EncodeToString(hash)]
|
||||
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
|
||||
return InvitationPreview{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
return InvitationPreview{Client: "Fake Co", Email: v.email,
|
||||
FullName: v.fullName, Role: v.role}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte,
|
||||
fullName, passwordHash string) (UserRecord, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.invites[hex.EncodeToString(hash)]
|
||||
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
|
||||
return UserRecord{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
if _, taken := f.users[v.email]; taken {
|
||||
return UserRecord{}, errors.New("app_users_email_idx")
|
||||
}
|
||||
// Marked spent BEFORE the account exists, mirroring the real store's one
|
||||
// transaction: a test that redeems the same code twice must get one user.
|
||||
v.used = true
|
||||
f.nextID++
|
||||
rec := UserRecord{
|
||||
ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID),
|
||||
// From the INVITATION, never from the request - which is the property
|
||||
// worth having a fake at all for.
|
||||
ClientID: v.clientID, ClientName: "Fake Co", Email: v.email,
|
||||
FullName: fullName, Role: v.role, Active: true, Found: true,
|
||||
PasswordHash: passwordHash,
|
||||
}
|
||||
if rec.FullName == "" {
|
||||
rec.FullName = v.fullName
|
||||
}
|
||||
f.users[v.email] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []TeamMember
|
||||
for _, u := range f.users {
|
||||
if u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
out = append(out, TeamMember{ID: u.ID, Email: u.Email,
|
||||
FullName: u.FullName, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string,
|
||||
up TeamUpdate) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for email, u := range f.users {
|
||||
if u.ID != userID || u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
if up.Role != nil {
|
||||
u.Role = *up.Role
|
||||
}
|
||||
if up.Active != nil {
|
||||
u.Active = *up.Active
|
||||
if !u.Active {
|
||||
// The real store revokes in the same transaction; the fake
|
||||
// does it here so a test can prove "they have left" actually
|
||||
// signs them out rather than waiting twelve hours.
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f.users[email] = u
|
||||
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
|
||||
Role: u.Role, Active: u.Active}, nil
|
||||
}
|
||||
return TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
|
||||
func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []DeviceSession
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID != userID || s.revoked {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeviceSession{ID: s.id, Device: s.device,
|
||||
ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sessions[sessionID]
|
||||
// Scoped by user id, exactly as the real UPDATE is: a session id travels in
|
||||
// a list and is not a secret, so it must not sign anybody else out.
|
||||
if !ok || s.p.UserID != userID || s.revoked {
|
||||
return errors.New("no such session")
|
||||
}
|
||||
s.revoked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
n := 0
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID && s.id != keep && !s.revoked {
|
||||
s.revoked = true
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string,
|
||||
jpeg []byte) (string, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.faces == nil {
|
||||
f.faces = map[string][]byte{}
|
||||
}
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID)
|
||||
f.faces[clientID+"/"+id] = jpeg
|
||||
f.lastFaceClient, f.lastFaceSite = clientID, siteID
|
||||
return "db:" + id, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")]
|
||||
if !ok {
|
||||
return nil, errors.New("no such face image")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.faceDeleteErr != nil {
|
||||
return f.faceDeleteErr
|
||||
}
|
||||
for _, k := range keys {
|
||||
delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:"))
|
||||
f.deletedFaces = append(f.deletedFaces, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,27 +122,9 @@ func (s *Server) attachImages(r *http.Request, rows []Arrival) {
|
||||
for i := range rows {
|
||||
key := rows[i].ImageKey
|
||||
rows[i].ImageKey = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
rows[i].Image.Reason = "This system is not storing customer photos."
|
||||
case key == "":
|
||||
rows[i].Image.Reason = "No photo was captured for this visit."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
// Log it, but never fail the feed over a picture. The visit is
|
||||
// the number the customer pays for; the photo is decoration on
|
||||
// top of it. This is the same rule the agent follows when an
|
||||
// upload fails.
|
||||
s.logf("ERROR presign arrival image: %v", err)
|
||||
rows[i].Image.Reason = "That photo could not be loaded."
|
||||
continue
|
||||
}
|
||||
rows[i].Image = Image{Available: true, URL: url,
|
||||
ExpiresIn: int(viewTTL.Seconds())}
|
||||
if rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
rows[i].Image = s.imageFor(key)
|
||||
if rows[i].Image.Available && rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ func (s *Server) attachSnapshots(cams []Camera) {
|
||||
Available: true,
|
||||
URL: "/api/cameras/" + cams[i].ID + "/snapshot.jpg",
|
||||
ExpiresIn: int(snapshotTTL.Seconds()),
|
||||
// Says out loud that this URL needs the session's bearer.
|
||||
// Clients used to infer it from the URL being relative, which
|
||||
// is true today and stops being true the first time object
|
||||
// storage is served from this same host.
|
||||
Auth: true,
|
||||
}
|
||||
case key == "":
|
||||
cams[i].Snapshot.Reason = "No picture from this camera yet."
|
||||
|
||||
186
server/internal/api/handlers_faces.go
Normal file
186
server/internal/api/handlers_faces.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Face images held by this server, for a deployment with no object storage.
|
||||
//
|
||||
// Where a bucket IS configured nothing here is used: the agent keeps asking for
|
||||
// a presigned URL and the reader keeps getting a signed link, which never puts
|
||||
// a photograph through this process at all and is the right route at estate
|
||||
// scale. This is the fallback that stops "no S3 account" from meaning "no
|
||||
// customer photo, ever", which is what every local install and every
|
||||
// self-hosted customer got - including on the mobile arrivals feed, whose whole
|
||||
// job is to put a face in front of somebody.
|
||||
//
|
||||
// Migration 011 carries the argument for why this is bounded and therefore safe
|
||||
// to keep in Postgres when per-visit images are not: one row survives per
|
||||
// customer, so it grows with the customer base and not with footfall.
|
||||
|
||||
// maxFaceBytes caps one upload. The engine writes ~20 KB crops; 2 MB is
|
||||
// generous for a large one and small enough that a misbehaving agent cannot use
|
||||
// this as free storage.
|
||||
const maxFaceBytes = 2 << 20
|
||||
|
||||
// faceMaxAge is how long a client may reuse a face it has already fetched.
|
||||
// The image for a given key never changes - a newer view gets a new key - so
|
||||
// this is only bounded to keep a signed-out device from holding one for ever.
|
||||
const faceMaxAge = 5 * time.Minute
|
||||
|
||||
// handlePutFace takes one face crop from a shop PC.
|
||||
//
|
||||
// The client and site come from the agent's own credential and are never read
|
||||
// off the request, so a shop PC physically cannot file an image under another
|
||||
// tenant - the same rule every other agent-authenticated write here follows.
|
||||
//
|
||||
// The response is a KEY, which the agent then puts on the queued visit exactly
|
||||
// as it does with a bucket object. That symmetry is deliberate: the two storage
|
||||
// routes differ in one hop and in nothing else, so the ingest path, the read
|
||||
// path and erasure all stay single implementations.
|
||||
func (s *Server) handlePutFace(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxFaceBytes+1))
|
||||
if err != nil || len(body) > maxFaceBytes {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "too_large",
|
||||
fmt.Sprintf("A face image must be under %d KB.", maxFaceBytes/1024))
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
badRequest(w, "the image is empty")
|
||||
return
|
||||
}
|
||||
// Checked against the bytes, never the Content-Type header. This endpoint
|
||||
// stores what it is handed and serves it back to a browser, so the one
|
||||
// thing it must not become is a way to park arbitrary content under a URL
|
||||
// this server will serve.
|
||||
if !isJPEG(body) {
|
||||
badRequest(w, "a face image must be a JPEG")
|
||||
return
|
||||
}
|
||||
|
||||
key, err := s.Store.PutVisitFace(r.Context(), ap.ClientID, ap.SiteID, body)
|
||||
if err != nil {
|
||||
s.serverError(w, "store face", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"key": key})
|
||||
}
|
||||
|
||||
// handleGetFace serves one back to a signed-in person.
|
||||
//
|
||||
// Session-authenticated rather than a signed link, and that is the same call
|
||||
// camera snapshots already made: there is no third party to delegate to - the
|
||||
// bytes are in our own database - and minting an unauthenticated URL so that a
|
||||
// plain <img src> could load it would add a way to reach a photograph of
|
||||
// somebody's customer with no session at all.
|
||||
//
|
||||
// The consequence is a real one and clients must handle it: a browser <img>
|
||||
// cannot send an Authorization header, so the web app fetches this and hands
|
||||
// over an object URL. A mobile image view can attach the header directly. The
|
||||
// `auth` flag on every Image says which kind of URL it is holding.
|
||||
//
|
||||
// No audit row is written here. Every read of a face is recorded where the LINK
|
||||
// is handed out - the arrivals page writes one row per page, the customer
|
||||
// record one per look - and the two paths must not disagree about what counts
|
||||
// as a read. Recording the byte fetch as well would double-count the DB
|
||||
// deployment and leave the bucket deployment, whose bytes never touch this
|
||||
// server, counted once.
|
||||
func (s *Server) handleGetFace(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
img, err := s.Store.VisitFace(r.Context(), p.ClientID, faceKey(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "no_image", "There is no photo here.")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(img)))
|
||||
w.Header().Set("Cache-Control", "private, max-age="+
|
||||
strconv.Itoa(int(faceMaxAge.Seconds())))
|
||||
// A photograph of a customer must not travel to a third party in a Referer
|
||||
// header if this URL is ever rendered inside a page that links out.
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if _, err := w.Write(img); err != nil && !errors.Is(err, http.ErrHandlerTimeout) {
|
||||
s.logf("WARN write face: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// faceKey rebuilds the stored key from the id in the path.
|
||||
//
|
||||
// The route is `/api/faces/{id}.jpg` so a client can hand the URL to an image
|
||||
// view that decides what to do by extension, and the `.jpg` is presentation
|
||||
// rather than part of the key.
|
||||
func faceKey(id string) string {
|
||||
if n := len(id); n > 4 && id[n-4:] == ".jpg" {
|
||||
id = id[:n-4]
|
||||
}
|
||||
return dbKeyPrefix + id
|
||||
}
|
||||
|
||||
// dbKeyPrefix mirrors store.DBKeyPrefix. Duplicated rather than imported
|
||||
// because this package must not depend on the concrete store - the whole point
|
||||
// of the Store interface - and it is a wire constant that changing on one side
|
||||
// alone would break loudly and immediately in the tests either way.
|
||||
const dbKeyPrefix = "db:"
|
||||
|
||||
// isDBKey reports whether an image key names a row here rather than an object
|
||||
// in a bucket.
|
||||
func isDBKey(key string) bool {
|
||||
return len(key) > len(dbKeyPrefix) && key[:len(dbKeyPrefix)] == dbKeyPrefix
|
||||
}
|
||||
|
||||
// faceURL is the path a client fetches for a stored face.
|
||||
func faceURL(key string) string {
|
||||
return "/api/faces/" + key[len(dbKeyPrefix):] + ".jpg"
|
||||
}
|
||||
|
||||
// imageFor turns one stored image key into the Image a client receives.
|
||||
//
|
||||
// ONE function decides this, for every surface: the arrivals feed, the live
|
||||
// stream, the customer record. There are now two places an image can live and
|
||||
// four distinct reasons there may not be one, and the failure this avoids is
|
||||
// the one the shops screen already hit once - two surfaces computing the same
|
||||
// fact separately and disagreeing about it in front of a user.
|
||||
//
|
||||
// A missing photo is DATA, not an error. Images are off by default across the
|
||||
// whole product, so on most deployments every arrival legitimately has none; a
|
||||
// client that renders a failure state would show a screen of red for a system
|
||||
// working exactly as configured. The two absences are told apart because a shop
|
||||
// can act on one and not the other.
|
||||
func (s *Server) imageFor(key string) Image {
|
||||
switch {
|
||||
case key == "" && s.Blob == nil:
|
||||
return Image{Reason: "This system is not storing customer photos."}
|
||||
case key == "":
|
||||
return Image{Reason: "No photo was captured for this visit."}
|
||||
|
||||
case isDBKey(key):
|
||||
// Held by this server. A relative URL that needs the caller's session -
|
||||
// see handleGetFace for why it is not a signed link - so it carries no
|
||||
// expiry: it is valid for exactly as long as the session is.
|
||||
return Image{Available: true, URL: faceURL(key), Auth: true}
|
||||
|
||||
case s.Blob == nil:
|
||||
// A bucket key on a server with no bucket. Only reachable if object
|
||||
// storage was configured once and has since been removed, and it is
|
||||
// worth its own sentence: the photo exists somewhere and this
|
||||
// deployment can no longer reach it, which is a configuration problem
|
||||
// rather than a customer with no picture.
|
||||
return Image{Reason: "This server can no longer reach its image storage."}
|
||||
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
// Logged, never fatal. The visit is the number the customer pays
|
||||
// for; the photo is decoration on top of it. Same rule the agent
|
||||
// follows when an upload fails.
|
||||
s.logf("ERROR presign image: %v", err)
|
||||
return Image{Reason: "That photo could not be loaded."}
|
||||
}
|
||||
return Image{Available: true, URL: url, ExpiresIn: int(viewTTL.Seconds())}
|
||||
}
|
||||
}
|
||||
@@ -91,31 +91,36 @@ func (s *Server) handleVisitorImage(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.")
|
||||
return
|
||||
}
|
||||
if s.Blob == nil {
|
||||
writeErr(w, http.StatusNotFound, "images_disabled",
|
||||
"This server does not store images.")
|
||||
return
|
||||
}
|
||||
key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id)
|
||||
if err != nil || key == "" {
|
||||
writeErr(w, http.StatusNotFound, "no_image",
|
||||
"There is no photo for this customer.")
|
||||
return
|
||||
}
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
s.serverError(w, "presign read", err)
|
||||
key = ""
|
||||
}
|
||||
// The same function every other surface uses. Two ways to answer "where is
|
||||
// this person's photo" would eventually answer differently, and the one
|
||||
// that mattered would be whichever the customer was looking at.
|
||||
img := s.imageFor(key)
|
||||
if !img.Available {
|
||||
// Absence, with the reason. `no_image` and `images_disabled` are
|
||||
// separate codes because the desktop and mobile clients act on them
|
||||
// differently: one is a customer with no picture yet, the other is a
|
||||
// deployment that stores none and should stop asking.
|
||||
code := "no_image"
|
||||
if key == "" && s.Blob == nil {
|
||||
code = "images_disabled"
|
||||
}
|
||||
writeErr(w, http.StatusNotFound, code, img.Reason)
|
||||
return
|
||||
}
|
||||
// Every read of a face image is worth a row. If a client asks "who looked
|
||||
// at my customers", an audit trail is the only answer that is not a guess.
|
||||
// Recorded HERE, where the link is handed out, for both storage routes -
|
||||
// the bucket's bytes never touch this server, so the fetch itself is not a
|
||||
// place both paths could be counted.
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "image.view", Entity: "visitor", EntityID: id,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"url": url, "expires_in": int(viewTTL.Seconds()),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, img)
|
||||
}
|
||||
|
||||
// handleForgetVisitor is the erasure path.
|
||||
@@ -146,8 +151,21 @@ func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, "list images for erasure", err)
|
||||
return
|
||||
}
|
||||
// Images this server holds itself. Deleted before the row, for the same
|
||||
// reason the bucket objects are: if the database commits first and this
|
||||
// fails, the keys are gone and nothing knows which images to remove.
|
||||
if err := s.Store.DeleteVisitFaces(r.Context(), p.ClientID, keys); err != nil {
|
||||
s.logf("ERROR erasure %s: cannot delete stored faces: %v", id, err)
|
||||
writeErr(w, http.StatusBadGateway, "storage_error",
|
||||
"The photo could not be deleted, so nothing was erased. "+
|
||||
"Please try again.")
|
||||
return
|
||||
}
|
||||
if s.Blob != nil {
|
||||
for _, key := range keys {
|
||||
if isDBKey(key) {
|
||||
continue // already gone, above
|
||||
}
|
||||
if err := s.Blob.Delete(r.Context(), key); err != nil {
|
||||
// Refuse the whole request. Reporting an erasure as done while
|
||||
// a face image is still in the bucket is the one outcome this
|
||||
|
||||
80
server/internal/api/handlers_sessions.go
Normal file
80
server/internal/api/handlers_sessions.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Which devices are signed in, and signing one of them out.
|
||||
//
|
||||
// This is the point of opaque tokens in a table rather than JWTs, and until now
|
||||
// the product had the cost of that choice without the benefit. The argument
|
||||
// recorded for it was that this system puts customer data on shop-floor PCs and
|
||||
// staff phones that get lost, resold and shared between people, so "log that
|
||||
// device out, now" has to actually work - and there was no endpoint that could
|
||||
// list what was signed in, let alone stop one.
|
||||
//
|
||||
// It matters most on mobile, which is why it arrives with it: a phone is the
|
||||
// device most likely to leave the building in somebody's pocket.
|
||||
|
||||
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
rows, err := s.Store.UserSessions(r.Context(), p.UserID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list sessions", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []DeviceSession{}
|
||||
}
|
||||
// Marked here rather than in SQL: which session is "this one" is a property
|
||||
// of the request, and the store has no business knowing about requests.
|
||||
for i := range rows {
|
||||
rows[i].Current = rows[i].ID == p.SessionID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleRevokeSession signs one device out.
|
||||
//
|
||||
// A person may only revoke their OWN sessions - the store scopes the update by
|
||||
// user id, so a session id, which is not a secret and travels in the list
|
||||
// above, cannot be used to sign somebody else out. Removing a colleague's
|
||||
// access is a different question with a different answer: deactivate them
|
||||
// through the team endpoint, which revokes every session they have.
|
||||
func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such device.")
|
||||
return
|
||||
}
|
||||
if err := s.Store.RevokeUserSession(r.Context(), p.UserID, id); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such device.")
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "auth.session.revoke", Entity: "session", EntityID: id,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRevokeOtherSessions is "sign out everywhere else".
|
||||
//
|
||||
// It deliberately keeps the caller's own session. Somebody who has just lost a
|
||||
// phone should not also be signed out of the device in their hand, in the
|
||||
// middle of dealing with it.
|
||||
func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
n, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID)
|
||||
if err != nil {
|
||||
s.serverError(w, "revoke sessions", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "auth.session.revoke_others", Entity: "session",
|
||||
Detail: map[string]any{"count": n},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"signed_out": n})
|
||||
}
|
||||
390
server/internal/api/handlers_team.go
Normal file
390
server/internal/api/handlers_team.go
Normal file
@@ -0,0 +1,390 @@
|
||||
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
|
||||
}
|
||||
147
server/internal/api/sessions_test.go
Normal file
147
server/internal/api/sessions_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "Log that device out, now" is the entire argument for keeping sessions in a
|
||||
// table instead of issuing JWTs. These are the tests that the argument is
|
||||
// actually cashed in.
|
||||
|
||||
func sessionList(t *testing.T, s *Server, token string) []DeviceSession {
|
||||
t.Helper()
|
||||
rec := do(t, s, "GET", "/api/auth/sessions", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sessions: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out []DeviceSession
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loginAs(t *testing.T, s *Server, email, password, device string) Session {
|
||||
t.Helper()
|
||||
rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{
|
||||
"email": email, "password": password, "device": device})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func TestAPersonCanSeeAndSignOutTheirOwnDevices(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
phone := loginAs(t, s, "manager@acme.com", "correct horse battery", "Pixel 8")
|
||||
till := loginAs(t, s, "manager@acme.com", "correct horse battery", "Shop PC")
|
||||
|
||||
rows := sessionList(t, s, till.Token)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want two devices, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
var phoneID string
|
||||
for _, r := range rows {
|
||||
if r.Device == "Pixel 8" {
|
||||
phoneID = r.ID
|
||||
}
|
||||
// The device making the request must be labelled, or somebody signs
|
||||
// themselves out of the machine in their hand without meaning to.
|
||||
if r.Device == "Shop PC" && !r.Current {
|
||||
t.Error("the calling session is not marked current")
|
||||
}
|
||||
if r.Device == "Pixel 8" && r.Current {
|
||||
t.Error("another device is marked current")
|
||||
}
|
||||
}
|
||||
if phoneID == "" {
|
||||
t.Fatalf("the phone is not in the list: %+v", rows)
|
||||
}
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/auth/sessions/"+phoneID, till.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Immediately, not when the access token happens to expire. A lost phone is
|
||||
// the case this exists for and twelve hours is not an answer.
|
||||
if rec := do(t, s, "GET", "/api/auth/me", phone.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("the revoked device is still signed in, got %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", till.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the calling device was signed out too, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A session id travels in the list above and is not a secret. The store scopes
|
||||
// the revoke by user id so one cannot be used to sign a colleague out.
|
||||
func TestOneUserCannotRevokeAnothersSession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff")
|
||||
|
||||
victim := loginAs(t, s, "sam@acme.com", "correct horse battery", "Sam's phone")
|
||||
attacker := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop")
|
||||
|
||||
// The id is obtained the way an attacker would have to: it is not in the
|
||||
// attacker's own list at all, so this uses the real one directly.
|
||||
var victimID string
|
||||
for _, r := range sessionList(t, s, victim.Token) {
|
||||
victimID = r.ID
|
||||
}
|
||||
if rec := do(t, s, "DELETE", "/api/auth/sessions/"+victimID, attacker.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("one user revoked another's session, got %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", victim.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatal("the victim was signed out by somebody else")
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody who has just lost a phone must not also be signed out of the device
|
||||
// they are holding while they deal with it.
|
||||
func TestSignOutEverywhereElseKeepsTheCurrentDevice(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
lost := loginAs(t, s, "manager@acme.com", "correct horse battery", "Lost phone")
|
||||
old := loginAs(t, s, "manager@acme.com", "correct horse battery", "Old tablet")
|
||||
here := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop")
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/sessions/revoke-others", here.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke others: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
SignedOut int `json:"signed_out"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.SignedOut != 2 {
|
||||
t.Errorf("want two devices signed out, got %d", out.SignedOut)
|
||||
}
|
||||
for name, tok := range map[string]string{"lost phone": lost.Token, "old tablet": old.Token} {
|
||||
if rec := do(t, s, "GET", "/api/auth/me", tok, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s is still signed in, got %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := do(t, s, "GET", "/api/auth/me", here.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatal("signing out everywhere else signed out this device too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoutesNeedASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/auth/sessions"},
|
||||
{"DELETE", "/api/auth/sessions/" + acmeStaffID},
|
||||
{"POST", "/api/auth/sessions/revoke-others"},
|
||||
} {
|
||||
if rec := do(t, s, c.method, c.path, "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: want 401, got %d", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
349
server/internal/api/team_test.go
Normal file
349
server/internal/api/team_test.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Registration is by invitation, and almost everything worth testing here is a
|
||||
// property of that choice: what the code decides versus what the request
|
||||
// decides, and who is allowed to mint one.
|
||||
|
||||
// Real user ids are uuids and the id-addressed routes check the shape before
|
||||
// spending a database round trip. A fixture using "u5" would 404 on the guard
|
||||
// rather than on the rule under test - which is a test that passes for the
|
||||
// wrong reason, and would keep passing if tenant scoping were removed.
|
||||
const (
|
||||
acmeStaffID = "11111111-1111-4111-8111-111111111111"
|
||||
acmeOwnerID = "22222222-2222-4222-8222-222222222222"
|
||||
acmeOtherID = "33333333-3333-4333-8333-333333333333"
|
||||
)
|
||||
|
||||
func seedMember(fs *fakeStore, id, email, name, role string) {
|
||||
fs.addUser(email, "correct horse battery", UserRecord{
|
||||
ID: id, ClientID: "client-acme", ClientName: "Acme Retail",
|
||||
FullName: name, Role: role, Active: true,
|
||||
})
|
||||
}
|
||||
|
||||
func invite(t *testing.T, s *Server, token string, body map[string]any) Invitation {
|
||||
t.Helper()
|
||||
rec := do(t, s, "POST", "/api/team/invitations", token, body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("invite: got %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var inv Invitation
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
func TestAnInvitationBecomesAnAccountAndASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
inv := invite(t, s, sess.Token, map[string]any{
|
||||
"email": "Nikhil@Acme.com", "full_name": "Nikhil", "role": "staff"})
|
||||
if inv.Code == "" {
|
||||
t.Fatal("the response that mints a code must carry it - it is not recoverable later")
|
||||
}
|
||||
// Normalised on the way in, so the address somebody types at sign-in is the
|
||||
// one that was invited whatever case they used.
|
||||
if inv.Email != "nikhil@acme.com" {
|
||||
t.Errorf("email should be normalised, got %q", inv.Email)
|
||||
}
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password", "device": "Pixel 8"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register: got %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out Session
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A session, not just a 201. Sending somebody who has just chosen a
|
||||
// password to a sign-in form to type it again is the sort of thing that
|
||||
// gets blamed on the password.
|
||||
if out.Token == "" || out.RefreshToken == "" {
|
||||
t.Fatal("registration should sign the new member in")
|
||||
}
|
||||
if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" {
|
||||
t.Errorf("wrong account: %+v", out.User)
|
||||
}
|
||||
if out.User.ClientID != "client-acme" {
|
||||
t.Errorf("joined the wrong company: %q", out.User.ClientID)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "$2a$") {
|
||||
t.Error("password hash leaked into the registration response")
|
||||
}
|
||||
|
||||
// And the account works.
|
||||
again := login(t, s, "nikhil@acme.com", "a-good-long-password")
|
||||
if again.User.ID != out.User.ID {
|
||||
t.Error("registered account cannot sign in as itself")
|
||||
}
|
||||
}
|
||||
|
||||
// The single most important test in this file. A code is forwarded, pasted into
|
||||
// a chat, screenshotted; if the body could name the address or the role, one
|
||||
// staff invitation would be an owner account for anybody who saw it.
|
||||
func TestTheCodeDecidesTheAddressAndTheRoleNotTheRequest(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{
|
||||
"email": "nikhil@acme.com", "role": "staff"})
|
||||
|
||||
// Unknown fields are refused outright, which is the strongest form of this:
|
||||
// a client cannot even ask.
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password",
|
||||
"email": "attacker@example.com", "role": "owner"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a body naming an address or a role must be refused, got %d: %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// And redeemed properly, the account is still staff at the invited address.
|
||||
rec = do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
var out Session
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" {
|
||||
t.Fatalf("the invitation did not decide the account: %+v", out.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationIsSingleUse(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "one@acme.com"})
|
||||
|
||||
first := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first redemption: %d %s", first.Code, first.Body.String())
|
||||
}
|
||||
second := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "another-long-password"})
|
||||
if second.Code == http.StatusCreated {
|
||||
t.Fatal("a spent invitation created a second account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARevokedInvitationCannotBeRedeemed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "gone@acme.com"})
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
if rec.Code == http.StatusCreated {
|
||||
t.Fatal("a withdrawn invitation still worked")
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown, expired, spent and revoked are one answer. The difference only ever
|
||||
// helps somebody guessing, and the holder's next step is identical in all four.
|
||||
func TestAnInvalidCodeSaysNothingAboutWhy(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "used@acme.com"})
|
||||
_ = do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
|
||||
spent := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
invented := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": "AAAAAA-BBBBBB-CCCCCC-DDDDDD", "password": "a-good-long-password"})
|
||||
|
||||
if spent.Code != invented.Code || spent.Body.String() != invented.Body.String() {
|
||||
t.Fatalf("a spent code is distinguishable from an invented one:\n%d %s\n%d %s",
|
||||
spent.Code, spent.Body.String(), invented.Code, invented.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffCannotInviteAndAManagerCannotMintAnOwner(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff")
|
||||
seedUser(fs)
|
||||
|
||||
staff := login(t, s, "sam@acme.com", "correct horse battery")
|
||||
if rec := do(t, s, "POST", "/api/team/invitations", staff.Token,
|
||||
map[string]any{"email": "x@acme.com"}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("staff should not be able to invite, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// A manager promoting somebody past themselves is an escalation, and it is
|
||||
// the shape of this endpoint that would matter if a manager account were
|
||||
// ever taken over.
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, s, "POST", "/api/team/invitations", mgr.Token,
|
||||
map[string]any{"email": "boss@acme.com", "role": "owner"}); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("a manager minted an owner invitation, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 'admin' is a platform administrator, which is defined by having no company at
|
||||
// all. An invitation always carries one, so the role could never work - what it
|
||||
// could do is create the tenant-scoped row with role='admin' that adminOnly
|
||||
// exists to reject.
|
||||
func TestAnInvitationCannotMintAPlatformAdmin(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/team/invitations", sess.Token,
|
||||
map[string]any{"email": "root@acme.com", "role": "admin"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("admin should not be an invitable role, got %d: %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationIsScopedToTheInvitersCompany(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
acme := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
beta := login(t, s, "other@beta.com", "correct horse battery")
|
||||
|
||||
inv := invite(t, s, acme.Token, map[string]any{"email": "new@acme.com"})
|
||||
|
||||
// Beta cannot see it...
|
||||
rec := do(t, s, "GET", "/api/team/invitations", beta.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), "new@acme.com") {
|
||||
t.Fatalf("another tenant can see Acme's invitations: %s", rec.Body.String())
|
||||
}
|
||||
// ...nor withdraw it.
|
||||
if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, beta.Token, nil); rec.Code == http.StatusNoContent {
|
||||
t.Fatal("another tenant withdrew Acme's invitation")
|
||||
}
|
||||
}
|
||||
|
||||
// The preview is unauthenticated by necessity - the holder has no account yet -
|
||||
// so what it discloses is the whole question.
|
||||
func TestThePreviewShowsWhatToJoinAndNothingElse(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{
|
||||
"email": "nikhil@acme.com", "full_name": "Nikhil", "role": "manager"})
|
||||
|
||||
rec := do(t, s, "GET", "/api/auth/invitation?code="+inv.Code, "", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preview: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var prev InvitationPreview
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &prev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prev.Role != "manager" || prev.Email != "nikhil@acme.com" {
|
||||
t.Errorf("preview should say what is being joined: %+v", prev)
|
||||
}
|
||||
// It must not become a way to read a company's staff list or anything else
|
||||
// about it beyond the one line the code already asserts.
|
||||
if strings.Contains(rec.Body.String(), "manager@acme.com") {
|
||||
t.Error("the preview disclosed the inviter's address")
|
||||
}
|
||||
|
||||
if rec := do(t, s, "GET", "/api/auth/invitation?code=NOPE", "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("an invented code should 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationEnforcesThePasswordFloor(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
inv := invite(t, s, sess.Token, map[string]any{"email": "short@acme.com"})
|
||||
|
||||
rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "short"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a short password was accepted, got %d", rec.Code)
|
||||
}
|
||||
// And the invitation is NOT spent by a rejected attempt - otherwise one
|
||||
// mistyped password would cost the person their invitation.
|
||||
ok := do(t, s, "POST", "/api/auth/register", "", map[string]any{
|
||||
"code": inv.Code, "password": "a-good-long-password"})
|
||||
if ok.Code != http.StatusCreated {
|
||||
t.Fatalf("a failed attempt burned the invitation: %d %s", ok.Code, ok.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- team --
|
||||
|
||||
func TestDeactivatingSomebodySignsThemOutNow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedMember(fs, acmeStaffID, "leaver@acme.com", "Lee", "staff")
|
||||
mgr := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
leaver := login(t, s, "leaver@acme.com", "correct horse battery")
|
||||
|
||||
if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the leaver should be signed in to begin with, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec := do(t, s, "PATCH", "/api/team/"+acmeStaffID, mgr.Token, map[string]any{"active": false})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The whole point. An access token lives twelve hours, so without revoking
|
||||
// the session, "remove their access" would remove it sometime tomorrow -
|
||||
// which is not what anybody pressing that button believes they have done.
|
||||
if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a deactivated account is still signed in, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLastOwnerCannotRemoveThemselves(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedMember(fs, acmeOwnerID, "boss@acme.com", "Bea", "owner")
|
||||
sess := login(t, s, "boss@acme.com", "correct horse battery")
|
||||
|
||||
for _, body := range []map[string]any{{"active": false}, {"role": "staff"}} {
|
||||
rec := do(t, s, "PATCH", "/api/team/"+acmeOwnerID, sess.Token, body)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("the only owner removed themselves with %v: %d %s",
|
||||
body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamIsScopedToTheCallersCompany(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
seedMember(fs, acmeOtherID, "asha@acme.com", "Asha", "manager")
|
||||
beta := login(t, s, "other@beta.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/team", beta.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), "manager@acme.com") {
|
||||
t.Fatalf("another tenant's staff are visible: %s", rec.Body.String())
|
||||
}
|
||||
// And a uuid guessed from elsewhere changes nothing.
|
||||
// A real, well-formed id belonging to the OTHER tenant. The 404 must come
|
||||
// from the client scope in the UPDATE, not from the shape check above it.
|
||||
if rec := do(t, s, "PATCH", "/api/team/"+acmeOtherID, beta.Token,
|
||||
map[string]any{"role": "staff"}); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("cross-tenant team edit was not refused, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,16 @@ type Image struct {
|
||||
Available bool `json:"available"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
// Auth says the URL is one of ours and needs this session's bearer token,
|
||||
// rather than a presigned object-store link that carries its own signature.
|
||||
//
|
||||
// It exists because the two are genuinely different to fetch and a client
|
||||
// cannot tell them apart by looking. A browser <img> can load the signed
|
||||
// one and CANNOT load this one, so the web app fetches it and hands over an
|
||||
// object URL; a mobile image view can attach the header and load it
|
||||
// directly. Guessing from whether the URL is absolute would work today and
|
||||
// break the first time object storage lives on the same host.
|
||||
Auth bool `json:"auth,omitempty"`
|
||||
// Reason is user-facing prose, present only when Available is false.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Key is the object-store key, carried from the store to the handler that
|
||||
@@ -588,3 +598,100 @@ type CheckStep struct {
|
||||
Detail string `json:"detail"`
|
||||
Advice string `json:"advice,omitempty"`
|
||||
}
|
||||
|
||||
// ==================================================== team and invitations ==
|
||||
|
||||
// NewInvitation is an invitation about to be written. Only the hash crosses
|
||||
// this boundary; the plaintext code exists in the handler and in the one
|
||||
// response that returns it, and nowhere else.
|
||||
type NewInvitation struct {
|
||||
ClientID string
|
||||
Email string
|
||||
FullName string
|
||||
Role string
|
||||
CodeHash []byte
|
||||
InvitedBy string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Invitation is a pending invitation as a manager sees it. It carries no code:
|
||||
// the plaintext is returned exactly once, by the request that created it, and
|
||||
// is not recoverable afterwards. A code a support engineer can look up later is
|
||||
// a code anyone with support access can redeem.
|
||||
type Invitation struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
InvitedBy string `json:"invited_by,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// Code is present ONLY on the response that mints it.
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// InvitationPreview is what an unauthenticated client may learn from a code it
|
||||
// already holds: which company, for which address, in what role.
|
||||
//
|
||||
// Enough to render "Join TeNext Retail as a manager" before asking somebody to
|
||||
// choose a password, and no more. Unknown, expired, spent and revoked codes are
|
||||
// all one answer, for the reason enrolment already records: the difference only
|
||||
// helps somebody guessing, and the holder's next step is identical in all four
|
||||
// cases.
|
||||
type InvitationPreview struct {
|
||||
Client string `json:"client_name"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// Registration is a redeemed invitation turning into an account. The email and
|
||||
// role come from the INVITATION, never from the request body: a code forwarded
|
||||
// to somebody else must not become an account for them, and a staff invitation
|
||||
// must not be redeemed into an owner.
|
||||
type Registration struct {
|
||||
Code string
|
||||
FullName string
|
||||
Password string
|
||||
Device string
|
||||
}
|
||||
|
||||
// TeamMember is one person in a company, as the team screen lists them.
|
||||
type TeamMember struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
LastLoginAt string `json:"last_login_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// TeamUpdate changes one member. Both fields are optional; a nil means "leave
|
||||
// this alone", which is what lets one endpoint serve "make them a manager" and
|
||||
// "they have left" without either silently doing the other.
|
||||
type TeamUpdate struct {
|
||||
Role *string `json:"role,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
// ==================================================== devices and sessions ==
|
||||
|
||||
// DeviceSession is one signed-in device, as its owner sees it.
|
||||
//
|
||||
// This list is the point of opaque tokens rather than JWTs. The whole argument
|
||||
// for a session table was that "log that device out, now" has to actually work
|
||||
// on a product that puts customer data on shop-floor PCs and staff phones that
|
||||
// get lost, resold and shared - and until this existed there was no way to ask
|
||||
// what was signed in, let alone stop it.
|
||||
type DeviceSession struct {
|
||||
ID string `json:"id"`
|
||||
Device string `json:"device"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
// Current marks the session making this request, so a client can label it
|
||||
// and can warn before somebody signs themselves out of the device in their
|
||||
// hand.
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user