Files
Behavision/server/internal/store/api_team.go
Suriyakumarvijayanayagam 6068b2c3c7 Nobody could change their own password
POST /api/auth/password. The cost of its absence was measured today
rather than argued: rotating three production accounts took a shell on
the host, three round trips, and briefly left a PLATFORM ADMIN - the
account that reads every company on the estate - with the password
PASTE_IT_HERE, because a placeholder in a pasted command was taken
literally and there was no way to correct it from the product.

A manager could always reset somebody ELSE's password. A platform admin
could be reset by nobody: they have no client, so the team routes are
not theirs, and `provision user` on the host was the only route. For
software that puts accounts on shop-floor PCs and staff phones, this is
not a feature - it is what makes every other credential decision
recoverable.

Three decisions:

- **authed, not tenantOnly.** A session is not a company's data, and the
  account with no company is precisely the one that had no route. Scoping
  this by client would have reproduced the hole it exists to close, which
  is also why SetUserPassword is not scoped by client the way
  ResetMemberPassword beside it is. The user id comes from the verified
  session, never the request, so there is nothing to point at anyone else.

- **The current password is required.** An access token lives twelve
  hours and travels on devices that get lost and shared; without this a
  stolen one owns the account permanently instead of until it expires.

- **Every OTHER session is revoked, and the caller's is kept.** Somebody
  changing their password because they believe it is known must not have
  to wonder whether the device that already had it is still signed in -
  and must not be signed out of the one in their hand while dealing with
  it. A failure there is logged, not returned: the password IS changed by
  then, and reporting an error would send them to retry with a current
  password that no longer exists.

The suite's login() helper fatals on anything but 200, which is right
everywhere else and useless here - half of what these tests assert is
that a password has STOPPED working. loginCode() returns the status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 15:30:08 +05:30

444 lines
17 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
}
// SetUserPassword changes one account's password, by user id.
//
// Deliberately NOT scoped by client, unlike ResetMemberPassword beside it.
// That one is a manager acting on somebody else in their company, so the
// tenant is the boundary. This is an account acting on ITSELF, and the caller
// is the session - a platform admin has no client at all and was, before this,
// the one account nobody could change the password of without a shell on the
// host. Scoping by client here would have reproduced exactly that hole.
//
// The id comes from the verified session and never from the request, so there
// is nothing here for a caller to point at somebody else.
func (s *Store) SetUserPassword(ctx context.Context, userID, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE app_users SET password_hash = $2
WHERE id = $1::uuid AND active`, userID, hash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
// Deactivated mid-session: their sessions are already revoked, so this
// is unreachable in practice, and silently succeeding would report a
// password change that did not happen.
return fmt.Errorf("no active account %s", userID)
}
return nil
}