The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.
POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.
POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.
RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.
Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.
API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
417 lines
15 KiB
Go
417 lines
15 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
)
|
|
|
|
// Adding people to a company, and taking them out again.
|
|
//
|
|
// Registration here is by invitation only. `handlers_team.go` carries the
|
|
// product argument; what matters at this layer is that every statement is
|
|
// scoped by the CALLER'S client id, taken from their session, so a manager
|
|
// cannot invite somebody into, list, or remove a member of a company that is
|
|
// not theirs by guessing a uuid.
|
|
|
|
// CreateInvitation writes a pending invitation for one company.
|
|
//
|
|
// The client id is not trusted from a caller anywhere above this, but it is
|
|
// still joined against `clients` here rather than inserted blind: a foreign-key
|
|
// violation surfaces as an opaque 500, and a row that names a company which has
|
|
// since been deleted is worse than a clean refusal.
|
|
func (s *Store) CreateInvitation(ctx context.Context, in api.NewInvitation) (api.Invitation, error) {
|
|
var out api.Invitation
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO invitations (client_id, email, full_name, role, code_hash,
|
|
invited_by, expires_at)
|
|
SELECT c.id, $2, $3, $4, $5, $6::uuid, $7
|
|
FROM clients c
|
|
WHERE c.id = $1::uuid
|
|
RETURNING id::text, email, full_name, role,
|
|
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
|
in.ClientID, in.Email, in.FullName, in.Role, in.CodeHash,
|
|
nullUUID(in.InvitedBy), in.ExpiresAt,
|
|
).Scan(&out.ID, &out.Email, &out.FullName, &out.Role,
|
|
&out.ExpiresAt, &out.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return api.Invitation{}, errors.New("no such company")
|
|
}
|
|
if err != nil {
|
|
return api.Invitation{}, fmt.Errorf("create invitation: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// PendingInvitations lists the invitations that have been sent and not yet
|
|
// taken up. Spent and revoked rows are history and are deliberately not here:
|
|
// the question this list answers is "who is still waiting to join".
|
|
func (s *Store) PendingInvitations(ctx context.Context, clientID string) ([]api.Invitation, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT i.id::text, i.email, i.full_name, i.role,
|
|
COALESCE(u.full_name, u.email, ''),
|
|
to_char(i.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
|
to_char(i.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
FROM invitations i
|
|
LEFT JOIN app_users u ON u.id = i.invited_by
|
|
WHERE i.client_id = $1::uuid
|
|
AND i.used_at IS NULL AND i.revoked_at IS NULL
|
|
AND i.expires_at > now()
|
|
ORDER BY i.created_at DESC`, clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list invitations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.Invitation
|
|
for rows.Next() {
|
|
var v api.Invitation
|
|
if err := rows.Scan(&v.ID, &v.Email, &v.FullName, &v.Role,
|
|
&v.InvitedBy, &v.ExpiresAt, &v.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeInvitation withdraws one before it is used.
|
|
//
|
|
// Scoped by client in the UPDATE, and it refuses an already-spent invitation
|
|
// rather than silently doing nothing: "I revoked it" and "somebody had already
|
|
// joined with it" need opposite follow-up actions from whoever asked.
|
|
func (s *Store) RevokeInvitation(ctx context.Context, clientID, id string) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE invitations SET revoked_at = now()
|
|
WHERE id = $2::uuid AND client_id = $1::uuid
|
|
AND used_at IS NULL AND revoked_at IS NULL`, clientID, id)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke invitation: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return errors.New("no such pending invitation")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InvitationByCode is the unauthenticated preview: what a holder may learn
|
|
// about a code they already have.
|
|
//
|
|
// Every way of not being valid returns the same error, so this cannot be used
|
|
// to tell an expired code from an invented one.
|
|
func (s *Store) InvitationByCode(ctx context.Context, hash []byte) (api.InvitationPreview, error) {
|
|
var out api.InvitationPreview
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT c.name, i.email, i.full_name, i.role
|
|
FROM invitations i
|
|
JOIN clients c ON c.id = i.client_id
|
|
WHERE i.code_hash = $1
|
|
AND i.used_at IS NULL AND i.revoked_at IS NULL
|
|
AND i.expires_at > now()`, hash,
|
|
).Scan(&out.Client, &out.Email, &out.FullName, &out.Role)
|
|
if err != nil {
|
|
return api.InvitationPreview{}, errors.New("that invitation is not valid")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// RedeemInvitation turns a code into an account, in ONE transaction.
|
|
//
|
|
// Two properties, and both were learned elsewhere in this system:
|
|
//
|
|
// - Single use is enforced BY the update. `used_at IS NULL` and the write are
|
|
// one statement, so two people racing on one invitation cannot both win.
|
|
// Check-then-update would be exactly that race, and the loser would get a
|
|
// second account rather than an error.
|
|
// - The account and the redemption commit together. A spent invitation with
|
|
// no user behind it is an invitation nobody can use and nobody can see is
|
|
// broken; a user with the invitation still open is a second account waiting
|
|
// to be created by anyone who was forwarded the code.
|
|
//
|
|
// The email and the role come from the ROW, never from the request. A code
|
|
// passed on to a colleague must not become an account for them, and a staff
|
|
// invitation must not be redeemed as an owner.
|
|
func (s *Store) RedeemInvitation(ctx context.Context, hash []byte,
|
|
fullName, passwordHash string) (api.UserRecord, error) {
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return api.UserRecord{}, err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
|
|
|
var clientID, email, role, invitedName string
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE invitations SET used_at = now()
|
|
WHERE code_hash = $1
|
|
AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now()
|
|
RETURNING client_id::text, email, role, full_name`, hash,
|
|
).Scan(&clientID, &email, &role, &invitedName)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return api.UserRecord{}, errors.New("that invitation is not valid")
|
|
}
|
|
if err != nil {
|
|
return api.UserRecord{}, fmt.Errorf("redeem invitation: %w", err)
|
|
}
|
|
|
|
if fullName == "" {
|
|
// The inviter may have typed a name; use it rather than leaving a
|
|
// blank row that every screen then renders as an email address.
|
|
fullName = invitedName
|
|
}
|
|
|
|
var rec api.UserRecord
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
|
VALUES ($1::uuid, $2, $3, $4, $5)
|
|
RETURNING id::text, email, full_name, role`,
|
|
clientID, email, passwordHash, fullName, role,
|
|
).Scan(&rec.ID, &rec.Email, &rec.FullName, &rec.Role)
|
|
if err != nil {
|
|
return api.UserRecord{}, fmt.Errorf("create user: %w", err)
|
|
}
|
|
|
|
var clientName string
|
|
if err := tx.QueryRow(ctx, `SELECT name FROM clients WHERE id = $1::uuid`,
|
|
clientID).Scan(&clientName); err != nil {
|
|
return api.UserRecord{}, err
|
|
}
|
|
|
|
// Recorded against the new account, not the inviter: this is the moment a
|
|
// person gained access, and the row should name who did.
|
|
rec.ClientID, rec.ClientName, rec.Active, rec.Found = clientID, clientName, true, true
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return api.UserRecord{}, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// Team lists the people in one company.
|
|
func (s *Store) Team(ctx context.Context, clientID string) ([]api.TeamMember, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id::text, email, full_name, role, active,
|
|
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
FROM app_users
|
|
WHERE client_id = $1::uuid
|
|
ORDER BY active DESC, full_name, email`, clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list team: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.TeamMember
|
|
for rows.Next() {
|
|
var m api.TeamMember
|
|
if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
|
&m.LastLoginAt, &m.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateTeamMember changes a role, or deactivates somebody who has left.
|
|
//
|
|
// Deactivating REVOKES their sessions in the same transaction. Leaving them
|
|
// live would mean "remove their access" removed it in twelve hours' time,
|
|
// whenever their access token happened to expire - which is not what anybody
|
|
// pressing that button believes they have just done, and is precisely the case
|
|
// an opaque-token session table exists to handle.
|
|
func (s *Store) UpdateTeamMember(ctx context.Context, clientID, userID string,
|
|
up api.TeamUpdate) (api.TeamMember, error) {
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return api.TeamMember{}, err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
|
|
|
var m api.TeamMember
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE app_users
|
|
SET role = COALESCE($3, role),
|
|
active = COALESCE($4, active)
|
|
WHERE id = $2::uuid AND client_id = $1::uuid
|
|
RETURNING id::text, email, full_name, role, active,
|
|
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
|
clientID, userID, up.Role, up.Active,
|
|
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
|
&m.LastLoginAt, &m.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return api.TeamMember{}, errors.New("no such team member")
|
|
}
|
|
if err != nil {
|
|
return api.TeamMember{}, fmt.Errorf("update team member: %w", err)
|
|
}
|
|
|
|
if up.Active != nil && !*up.Active {
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE sessions SET revoked_at = now()
|
|
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
|
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return api.TeamMember{}, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// OwnerCount counts the active owners of a company.
|
|
//
|
|
// Used to refuse the change that locks a company out of its own account: the
|
|
// last owner may not demote or deactivate themselves. There is no support path
|
|
// back from that except a shell on the server, which is the thing this whole
|
|
// surface exists to stop needing.
|
|
func (s *Store) OwnerCount(ctx context.Context, clientID string) (int, error) {
|
|
var n int
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT count(*) FROM app_users
|
|
WHERE client_id = $1::uuid AND role = 'owner' AND active`, clientID).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// ============================================================== sessions ====
|
|
|
|
// UserSessions lists one person's live sessions, newest first.
|
|
func (s *Store) UserSessions(ctx context.Context, userID string) ([]api.DeviceSession, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id::text, device,
|
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
|
COALESCE(to_char(last_used_at AT TIME ZONE 'UTC',
|
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
|
to_char(refresh_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
|
FROM sessions
|
|
WHERE user_id = $1::uuid AND revoked_at IS NULL
|
|
AND refresh_expires_at > now()
|
|
ORDER BY COALESCE(last_used_at, created_at) DESC`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.DeviceSession
|
|
for rows.Next() {
|
|
var d api.DeviceSession
|
|
if err := rows.Scan(&d.ID, &d.Device, &d.CreatedAt,
|
|
&d.LastUsedAt, &d.ExpiresAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeUserSession signs one device out.
|
|
//
|
|
// Scoped by user_id in the UPDATE, so a session id - which is not a secret and
|
|
// travels in a list - cannot be used to sign somebody else out.
|
|
func (s *Store) RevokeUserSession(ctx context.Context, userID, sessionID string) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE sessions SET revoked_at = now()
|
|
WHERE id = $2::uuid AND user_id = $1::uuid AND revoked_at IS NULL`,
|
|
userID, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke session: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return errors.New("no such session")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeOtherSessions is the "sign out everywhere else" button.
|
|
//
|
|
// It keeps the caller's own session deliberately: somebody who has just lost a
|
|
// phone should not also be signed out of the device they are holding, which
|
|
// would leave them re-authenticating in the middle of an emergency.
|
|
func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE sessions SET revoked_at = now()
|
|
WHERE user_id = $1::uuid AND id <> $2::uuid AND revoked_at IS NULL`,
|
|
userID, keepSessionID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("revoke sessions: %w", err)
|
|
}
|
|
return int(tag.RowsAffected()), nil
|
|
}
|
|
|
|
// CreateMember inserts an active account into a tenant.
|
|
//
|
|
// The email uniqueness constraint is global (migration 007), and a clash here
|
|
// is an ordinary typing mistake - somebody already has that address - so it
|
|
// surfaces as a conflict the manager can act on, not a 500.
|
|
func (s *Store) CreateMember(ctx context.Context, clientID string,
|
|
in api.NewMemberInput, hash string) (api.TeamMember, error) {
|
|
|
|
var m api.TeamMember
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
|
VALUES ($1::uuid, $2, $3, $4, $5)
|
|
RETURNING id::text, email, full_name, role, active, '',
|
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
|
clientID, in.Email, hash, in.FullName, in.Role,
|
|
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
|
&m.LastLoginAt, &m.CreatedAt)
|
|
if err != nil {
|
|
return api.TeamMember{}, fmt.Errorf("create member: %w", err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ResetMemberPassword replaces a member's password and signs them out
|
|
// everywhere, in one transaction.
|
|
//
|
|
// The two go together because of why a manager resets a password at all: the
|
|
// salesperson forgot it, or lost the phone it was saved on. In the second case
|
|
// the old sessions are the problem, and a reset that left them valid would
|
|
// look complete while changing nothing that mattered. Scoped to the caller's
|
|
// tenant in the UPDATE itself, so a user id from another company matches no
|
|
// row rather than being reset.
|
|
func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
|
|
hash string) (api.TeamMember, error) {
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return api.TeamMember{}, err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
|
|
|
var m api.TeamMember
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE app_users SET password_hash = $3
|
|
WHERE id = $2::uuid AND client_id = $1::uuid
|
|
RETURNING id::text, email, full_name, role, active,
|
|
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
|
|
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
|
|
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
|
|
clientID, userID, hash,
|
|
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
|
|
&m.LastLoginAt, &m.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return api.TeamMember{}, errors.New("no such team member")
|
|
}
|
|
if err != nil {
|
|
return api.TeamMember{}, fmt.Errorf("reset password: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE sessions SET revoked_at = now()
|
|
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
|
|
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return api.TeamMember{}, err
|
|
}
|
|
return m, nil
|
|
}
|