postgres

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package postgres is the PostgreSQL reference implementation of every store interface sulis defines: sulis.UserStore, sulis.SessionStore, sulis.TokenStore, passkey.Store, passkey.ChallengeStore, totp.Store, and recovery.Store.

It lives in the same module as its SQLite sibling, github.com/borfast/sulis/store/sql, so that a SQL driver never enters the dependency graph of an application that brings its own store. Depending on this package is opting in to github.com/jackc/pgx/v5; depending on sulis is not.

What it is for

memstore shows what the contracts mean with a mutex, and store/sql/sqlite shows what they mean in SQL on a database that has exactly one writer. This package shows what they mean on a database that does not: PostgreSQL runs every caller concurrently, so an atomicity requirement here has to be carried by a conditional statement, a row lock, or an advisory lock rather than by the engine happening to serialize. All three packages are held to the same bar — the whole storetest conformance suite, unmodified, passes against every one of them.

Concurrency

The pool is a normal pool: several connections, all writing at once. That is the point of choosing PostgreSQL over the single-file SQLite store, and it is why three of the contracts need more than the statement their SQLite counterparts get.

Most of them do not. PostgreSQL's READ COMMITTED isolation re-evaluates a blocked statement's WHERE clause against the row version the winner committed, which is exactly what a compare-and-swap needs: UpdateUser's "WHERE id = $1 AND version = $2", ConsumeToken's "WHERE used = false", ConsumeChallenge's and ConfirmEnrollment's "DELETE ... RETURNING", and DeleteSession's owner-scoped delete are all single statements whose losers see zero affected rows, without any lock this package takes itself.

Three do not survive that treatment, because what they check is the ABSENCE or the COUNT of rows, and a snapshot from before the winner committed answers both questions wrongly:

  • PasskeyStore.DeleteCredential's last-credential guard counts the user's credentials. Two callers deleting two different rows of a two-credential user both count 2, both pass, and the user is locked out.
  • TOTPStore.EnrollPending refuses to write when an active credential exists. A ConfirmEnrollment that has not committed yet is invisible, so the enrollment lands anyway and silently replaces a working factor.
  • TOTPStore.DeleteTOTP empties two tables. A ConfirmEnrollment can interleave between them and resurrect an active credential the caller believed it had removed.

Each of those takes a transaction-scoped advisory lock keyed on the user (see lockUser) as its first statement, which makes every mutation of one user's credentials serial while leaving different users fully concurrent. It is the smallest thing that reproduces what SQLite's single writer gave the sibling package for free. Advisory locks are released when the transaction ends, whether it commits or rolls back, so a failed call cannot strand one.

Every transaction here issues its lock or its first write immediately after BEGIN and holds no lock across a round trip it does not need, so the lock order is the same in every path and no two of these transactions can wait on each other.

Aliasing

The interfaces forbid a store from sharing mutable state with its callers in either direction. A store that reconstructs rows from a database read gets this for free, and this one does: nothing a caller passes in is retained, and every value handed back is built from column values.

Index

Constants

View Source
const DefaultChallengeTTL = 5 * time.Minute

DefaultChallengeTTL is how long a challenge stays consumable when ChallengeStore.TTL is left at zero, matching the lifetime of a WebAuthn ceremony.

View Source
const DriverName = "pgx"

DriverName is the database/sql driver name github.com/jackc/pgx/v5/stdlib registers. Importing this package registers it, since the driver is imported here.

Variables

View Source
var ErrChallengeNotFound = errors.New("sulis/postgres: challenge not found")

ErrChallengeNotFound is returned by ChallengeStore.ConsumeChallenge when no live challenge is stored under the key — already consumed, expired, or never saved. passkey.ChallengeStore leaves this error implementation-defined; the caller normalizes whatever comes back to passkey.ErrChallengeExpired.

View Source
var ErrTOTPCounterRegressed = errors.New("sulis/postgres: TOTP counter would regress")

ErrTOTPCounterRegressed is returned by TOTPStore.SaveTOTP when a save would lower LastUsedCounter for the active credential with the same ID. totp.Store requires such a save to be rejected but does not name the error; failing closed is the point, since a counter that can regress is a code that can be replayed.

View Source
var Schema string

Schema is the DDL every store in this package expects, exported so an adopter can hand it to their own migration tool instead of calling Migrate. It is written to be read: each constraint carries the contract it exists to enforce.

Functions

func Migrate

func Migrate(ctx context.Context, db *sql.DB) error

Migrate applies Schema. PostgreSQL's DDL is transactional, so every statement runs in one transaction and a database is either fully migrated or untouched. The DDL is written with IF NOT EXISTS throughout, so an adopter running it on every boot is not punished for it.

func SearchPathDSN

func SearchPathDSN(dsn, schema string) (string, error)

SearchPathDSN returns dsn with its search_path runtime parameter set to schema, which is how one PostgreSQL database holds several independent copies of this schema (the conformance tests give every test function its own; a multi-tenant deployment might give every tenant one).

The parameter is added through net/url rather than by concatenation, because concatenation is a silent bug rather than an error: a DSN that already carries "?sslmode=disable" would gain a second "?" and the driver would either reject it or drop everything after it, and a schema name containing "&" or "#" would truncate the DSN and connect somewhere other than where the caller asked. Both failures happen quietly, and one of them connects successfully to the wrong place.

Only the URL form of a DSN can be rewritten this way. A keyword/value DSN ("host=… user=…") is returned as an error rather than mangled — appending a query string to one produces a DSN that parses into a different connection than the caller wrote.

Types

type ChallengeStore

type ChallengeStore struct {

	// TTL is how long a saved challenge stays consumable. Zero — or any
	// non-positive value, since "expire before you were saved" is not a
	// coherent request — means DefaultChallengeTTL. The interface asks
	// implementations to expire entries after roughly the lifetime of a
	// WebAuthn ceremony; nothing above the store enforces that, so it is
	// enforced here.
	TTL time.Duration
	// contains filtered or unexported fields
}

ChallengeStore is the PostgreSQL passkey.ChallengeStore.

ConsumeChallenge is one DELETE ... RETURNING, so two concurrent finishes of the same ceremony cannot both receive the challenge — a replayed WebAuthn response must not be verifiable twice against one challenge. The second deleter blocks on the first's row lock and then finds nothing to return.

func (*ChallengeStore) ConsumeChallenge

func (s *ChallengeStore) ConsumeChallenge(ctx context.Context, key string) ([]byte, error)

ConsumeChallenge fetches and deletes the challenge stored under key in one statement, so only one caller can ever receive it.

An expired challenge is deleted and then refused, rather than filtered out of the DELETE: burning the row either way matches the "failures burn the token" direction the rest of this module takes, and leaves nothing behind for a second attempt to find.

func (*ChallengeStore) DeleteExpiredChallenges

func (s *ChallengeStore) DeleteExpiredChallenges(ctx context.Context) error

DeleteExpiredChallenges removes challenges nobody finished in time. The ChallengeStore interface has no sweep method — ConsumeChallenge already removes the row it reads, and an abandoned ceremony is the only way one is ever left behind — so this is offered for a caller that wants to run it on a timer rather than required by the contract.

func (*ChallengeStore) SaveChallenge

func (s *ChallengeStore) SaveChallenge(ctx context.Context, key string, sessionData []byte) error

SaveChallenge stores sessionData under key, replacing anything already there. The bytes are copied into the database, so a caller reusing its buffer afterwards cannot rewrite a challenge it already handed over.

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB owns a database handle and hands out the seven store implementations. All of them read and write through the same handle, so one DB is one database.

func New

func New(db *sql.DB) *DB

New wraps an already-configured *sql.DB. The caller keeps ownership: New neither applies Schema (call Migrate, or run the DDL through your own migration tool) nor closes the handle, and DB.Close on a DB built this way closes the handle the caller passed in.

func Open

func Open(ctx context.Context, dsn string) (*DB, error)

Open opens the database named by dsn, configures the connection pool, and applies Schema. dsn is anything pgx accepts: a postgres:// URL, a keyword/value string, or "" to take everything from the standard PG* environment variables.

The pool is given modest defaults — enough connections for a small service, a bounded idle set, and a bounded lifetime so a connection cannot outlive a failover or a pooler restart. Tune them on the handle SQL returns, or build the handle yourself and use New.

The caller owns the returned DB and must Close it.

func (*DB) ChallengeStore

func (d *DB) ChallengeStore() *ChallengeStore

ChallengeStore returns the passkey.ChallengeStore backed by this database, with the default challenge lifetime. Set its TTL field to change it.

func (*DB) Close

func (d *DB) Close() error

Close closes the underlying handle.

func (*DB) PasskeyStore

func (d *DB) PasskeyStore() *PasskeyStore

PasskeyStore returns the passkey.Store backed by this database.

func (*DB) RecoveryStore

func (d *DB) RecoveryStore() *RecoveryStore

RecoveryStore returns the recovery.Store backed by this database.

func (*DB) SQL

func (d *DB) SQL() *sql.DB

SQL returns the underlying handle, for callers that need to run their own statements against the same database (a health check, a report, a migration of their own tables).

func (*DB) SessionStore

func (d *DB) SessionStore() *SessionStore

SessionStore returns the sulis.SessionStore backed by this database.

func (*DB) TOTPStore

func (d *DB) TOTPStore() *TOTPStore

TOTPStore returns the totp.Store backed by this database.

func (*DB) TokenStore

func (d *DB) TokenStore() *TokenStore

TokenStore returns the sulis.TokenStore backed by this database.

func (*DB) UserStore

func (d *DB) UserStore() *UserStore

UserStore returns the sulis.UserStore backed by this database.

type PasskeyStore

type PasskeyStore struct {
	// contains filtered or unexported fields
}

PasskeyStore is the PostgreSQL passkey.Store.

DeleteCredential is where the danger lives, and it is the only method here that needs a transaction. Its guard asks how MANY credentials the user has, and that is the one question a snapshot answers wrongly under concurrency: two callers each deleting a different one of a user's last two credentials both count 2, both pass the allowLast == false guard, and both succeed — the exact lockout the guard exists to prevent, reached through the guarded path. Row locks do not help, because the two callers lock different rows. See DeleteCredential for what does.

func (*PasskeyStore) DeleteCredential

func (s *PasskeyStore) DeleteCredential(ctx context.Context, userID, id string, allowLast bool) error

DeleteCredential removes the credential identified by id if it belongs to userID, refusing to remove userID's last one unless allowLast is set:

DELETE FROM passkey_credentials
 WHERE id = $1 AND user_id = $2
   AND ($3 OR (SELECT COUNT(*) FROM passkey_credentials WHERE user_id = $2) > 1)

One statement still carries the ownership check, the count check, and the removal — but on PostgreSQL that is not enough on its own, and this is the clearest example in the package of why. Two callers deleting DIFFERENT rows take different row locks, so neither blocks; each counts the user's credentials in its own snapshot, both see 2, both pass the guard, and the user ends with none. The transaction therefore opens by taking the user's advisory lock, which makes deletions for one user serial and leaves deletions for different users fully concurrent. The loser then counts 1 and is refused, which is the answer the guard exists to give.

Zero rows affected means either that the guard refused or that (id, userID) names nothing, and those are different errors to the caller — passkey.ErrLastCredential versus passkey.ErrPasskeyNotFound — so a follow-up existence check inside the same transaction tells them apart.

func (*PasskeyStore) DeleteCredentialsByUserID

func (s *PasskeyStore) DeleteCredentialsByUserID(ctx context.Context, userID string) error

DeleteCredentialsByUserID removes every credential owned by userID, with no last-credential guard: deleting a whole account is a stronger action the caller has already gated, and leaving one credential behind because it happened to be last would be surprising here.

func (*PasskeyStore) GetCredentialByID

func (s *PasskeyStore) GetCredentialByID(ctx context.Context, credentialID []byte) (*passkey.Credential, error)

GetCredentialByID returns the credential whose raw WebAuthn credential ID is credentialID, or passkey.ErrPasskeyNotFound.

func (*PasskeyStore) GetCredentialsByUserID

func (s *PasskeyStore) GetCredentialsByUserID(ctx context.Context, userID string) ([]passkey.Credential, error)

GetCredentialsByUserID returns every credential owned by userID. A user with no credentials is not an error: it is how a caller learns the user has no passkey enrolled.

func (*PasskeyStore) RenameCredential

func (s *PasskeyStore) RenameCredential(ctx context.Context, id, name string) error

RenameCredential sets a credential's display name — caller-supplied metadata passkey itself never generates or validates. Zero rows affected is passkey.ErrPasskeyNotFound.

func (*PasskeyStore) SaveCredential

func (s *PasskeyStore) SaveCredential(ctx context.Context, cred *passkey.Credential) error

SaveCredential persists cred, replacing any credential already stored under the same ID. Registration is the caller; the bookkeeping that changes on every later login goes through UpdateCredentialAfterLogin instead.

func (*PasskeyStore) UpdateCredentialAfterLogin

func (s *PasskeyStore) UpdateCredentialAfterLogin(ctx context.Context, credentialID []byte, signCount uint32, backupState bool, lastUsedAt time.Time) error

UpdateCredentialAfterLogin persists the three fields that must change together after every successful assertion: SignCount (clone detection), BackupState (which can flip independently of BackupEligible), and LastUsedAt. go-webauthn re-reads all three on the next ceremony, so a store that persists only some of them breaks the credential's NEXT login rather than this one — which is why they are one statement and one method.

Zero rows affected is passkey.ErrPasskeyNotFound.

type RecoveryStore

type RecoveryStore struct {
	// contains filtered or unexported fields
}

RecoveryStore is the PostgreSQL recovery.Store.

A recovery code is a single-use bypass of every other factor, so ConsumeCode's lookup and delete are one statement: a store that looks a code up and then deletes it lets two concurrent presentations of the same code both succeed — one code, two authentications. The composite primary key on (user_id, code_hash) also scopes a code to its owner, so presenting someone else's code hash matches no row at all.

func (*RecoveryStore) ConsumeCode

func (s *RecoveryStore) ConsumeCode(ctx context.Context, userID, hash string) error

ConsumeCode deletes the code matching userID and hash, in one statement. Zero rows affected — spent, never issued, or issued to somebody else — is recovery.ErrCodeNotFound.

func (*RecoveryStore) CountCodes

func (s *RecoveryStore) CountCodes(ctx context.Context, userID string) (int, error)

CountCodes returns how many unused codes the user has left. A user with none is not an error: zero is the answer, and it is the answer an application shows on a "you have no recovery codes left" screen.

func (*RecoveryStore) DeleteCodes

func (s *RecoveryStore) DeleteCodes(ctx context.Context, userID string) error

DeleteCodes removes every code for the user. Removing nothing is not an error.

func (*RecoveryStore) ReplaceCodes

func (s *RecoveryStore) ReplaceCodes(ctx context.Context, userID string, hashes []string) error

ReplaceCodes replaces the user's whole code set inside one transaction, so no reader ever sees the old set gone and the new set not yet there. Regenerating replaces, never adds: an empty or nil hashes simply clears the set.

The DELETE is the transaction's first statement, so the user's rows are locked before anything is inserted and two concurrent replacements for one user serialize on it rather than interleaving into a mixed set.

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore is the PostgreSQL sulis.SessionStore.

The requirement worth the most here is DeleteSession's scoping: the membership check and the removal are one statement keyed on both columns, and zero affected rows is an error rather than a silent success. That is what makes cross-user revocation impossible through Sulis.RevokeSession, which passes the caller's own user ID — a store that ignored the user ID, or reported success when it deleted nothing, would hand anyone who learned a session ID the power to sign other people out.

func (*SessionStore) CleanExpired

func (s *SessionStore) CleanExpired(ctx context.Context) error

CleanExpired removes every session whose absolute expiry has passed. Idle expiry is deliberately not swept here: IdleExpiresAt moves forward on every throttled touch, ValidateSession enforces it on read, and a session left behind by an idle timeout is unusable rather than dangerous.

func (*SessionStore) CreateSession

func (s *SessionStore) CreateSession(ctx context.Context, session *sulis.Session) error

CreateSession inserts session.

func (*SessionStore) DeleteSession

func (s *SessionStore) DeleteSession(ctx context.Context, userID, id string) error

DeleteSession removes the session identified by id if it belongs to userID:

DELETE FROM sessions WHERE id = $1 AND user_id = $2

Zero rows affected — whether id names nothing at all or names someone else's session — is sulis.ErrSessionNotFound. The two cases are deliberately not distinguished: telling a caller "that session exists but is not yours" would answer a question they have no business asking.

Two callers deleting the same row race inside PostgreSQL, not here: the second blocks on the first's row lock and then finds the row gone, so exactly one of them reports success.

func (*SessionStore) DeleteUserSessions

func (s *SessionStore) DeleteUserSessions(ctx context.Context, userID string) error

DeleteUserSessions removes every session belonging to userID. Matching nothing is not an error.

func (*SessionStore) DeleteUserSessionsExcept

func (s *SessionStore) DeleteUserSessionsExcept(ctx context.Context, userID, keepSessionID string) error

DeleteUserSessionsExcept removes every session belonging to userID except keepSessionID — "sign out everywhere else". keepSessionID naming nothing, or naming another user's session, is not an error: every OTHER session for userID goes regardless.

func (*SessionStore) GetSessionByTokenHash

func (s *SessionStore) GetSessionByTokenHash(ctx context.Context, tokenHash string) (*sulis.Session, error)

GetSessionByTokenHash returns the session with the given token hash, or sulis.ErrSessionNotFound. Sessions are only ever looked up by hash; the raw token is never stored.

func (*SessionStore) ListUserSessions

func (s *SessionStore) ListUserSessions(ctx context.Context, userID string) ([]sulis.Session, error)

ListUserSessions returns every session belonging to userID, in no promised order. Matching nothing is not an error.

TokenHash comes back exactly as stored, the same as GetSessionByTokenHash: blanking it before an application sees it is Sulis.ListUserSessions's job, done once there rather than depended on here.

func (*SessionStore) TouchSession

func (s *SessionStore) TouchSession(ctx context.Context, id string, lastSeen time.Time, idleExpires *time.Time) error

TouchSession stamps the session identified by id with a fresh lastSeen and idleExpires, touching no other column. Zero rows affected is sulis.ErrSessionNotFound.

A nil idleExpires is written as SQL NULL, clearing whatever was there: an application that enables idle expiry and later disables it must not have the old deadline linger and quietly start enforcing itself again.

func (*SessionStore) UpdateAuthenticatedAt

func (s *SessionStore) UpdateAuthenticatedAt(ctx context.Context, id string, at time.Time) error

UpdateAuthenticatedAt stamps the session identified by id with at, touching no other column. Zero rows affected is sulis.ErrSessionNotFound.

This is ReAuthenticate's write path: it refreshes how recently the session's owner proved their credential without minting a new session or rotating its token.

type TOTPStore

type TOTPStore struct {
	// contains filtered or unexported fields
}

TOTPStore is the PostgreSQL totp.Store.

The active and pending slots are two tables, both keyed by user_id, so "at most one of each per user" is a primary key rather than a convention. Every transition between them is expressed the same way its SQLite sibling expresses it — an INSERT ... SELECT ... WHERE NOT EXISTS upsert for EnrollPending, a DELETE ... RETURNING feeding an upsert for ConfirmEnrollment, a conditional upsert that refuses to lower the counter for SaveTOTP, two deletes in one transaction for DeleteTOTP.

Every mutation here also opens by taking the user's advisory lock, which the SQLite store did not need because SQLite has one writer. Two of these operations ask about the ABSENCE of a row (EnrollPending: "is there an active credential?") or span two tables (DeleteTOTP, ConfirmEnrollment), and a snapshot taken before a concurrent writer committed answers both wrongly: an enrollment would silently replace a factor a racing confirmation had just promoted, and a deletion would leave behind an active credential a racing confirmation slipped in between its two DELETEs. The lock makes one user's TOTP writes serial and leaves different users concurrent. Reads take no lock: they answer about one row, at one instant, which is all their contract promises.

func (*TOTPStore) ConfirmEnrollment

func (s *TOTPStore) ConfirmEnrollment(ctx context.Context, userID, pendingID string, counter uint64) (*totp.Credential, error)

ConfirmEnrollment promotes userID's pending enrollment to active, but only while it is still the one named by pendingID — the enrollment whose secret the caller just matched a code against.

The comparison and the promotion are one transaction whose first write is the DELETE that consumes the pending row:

DELETE FROM totp_pending WHERE user_id = $1 AND id = $2 RETURNING ...

Returning no row means pendingID no longer matches — already promoted, superseded by a racing enrollment, or never there — so nothing is touched and totp.ErrTOTPNotEnrolled comes back, which the caller treats exactly like "nothing to confirm".

counter is the time step the code was matched at. When an active credential already exists (this is a replacement, not a first enrollment) the promoted credential keeps whichever counter is higher, so swapping factors can never roll the user's replay-protection clock backwards.

func (*TOTPStore) DeleteTOTP

func (s *TOTPStore) DeleteTOTP(ctx context.Context, userID string) error

DeleteTOTP removes userID's active credential and any pending enrollment, both inside one transaction and under the user's lock. Removing them one after the other would let a concurrent ConfirmEnrollment promotion land in the gap, leaving the just-promoted credential behind as an active factor the caller believed it had removed entirely — and on PostgreSQL the transaction alone does not close that gap, because the promotion writes a row this transaction's DELETE never saw and so never locked. Removing nothing is not an error.

func (*TOTPStore) EnrollPending

func (s *TOTPStore) EnrollPending(ctx context.Context, cred *totp.Credential) error

EnrollPending stores cred as userID's pending enrollment, but only if the user has no active credential:

INSERT INTO totp_pending (user_id, id, secret, created_at)
SELECT $1, $2, $3, $4
 WHERE NOT EXISTS (SELECT 1 FROM totp_active WHERE user_id = $1)
    ON CONFLICT (user_id) DO UPDATE SET ...

The guard is the statement's own WHERE clause, so it cannot be split from the write. On PostgreSQL that alone is not sufficient, because NOT EXISTS is answered from a snapshot: a ConfirmEnrollment that has already inserted into totp_active but not yet committed is invisible, so the check would pass and this write would land undetected, silently replacing a factor the user relies on. The user's advisory lock closes that window — a racing confirmation either has committed (and NOT EXISTS sees it) or has not started (and this call commits first, so the confirmation's own DELETE ... RETURNING finds a different pending row and refuses).

Zero rows affected therefore means an active credential exists — totp.ErrTOTPAlreadyEnrolled, which Service.Enroll surfaces unchanged.

Any pending enrollment already on file is superseded either way: at most one exists per user, and an unconfirmed one has nothing worth protecting.

func (*TOTPStore) GetActiveTOTP

func (s *TOTPStore) GetActiveTOTP(ctx context.Context, userID string) (*totp.Credential, error)

GetActiveTOTP returns userID's active (verified) credential — the one Validate checks codes against — or totp.ErrTOTPNotEnrolled, whether or not a pending enrollment exists.

func (*TOTPStore) GetPendingTOTP

func (s *TOTPStore) GetPendingTOTP(ctx context.Context, userID string) (*totp.Credential, error)

GetPendingTOTP returns userID's pending (unverified) enrollment awaiting ConfirmEnrollment, or totp.ErrTOTPNotEnrolled.

func (*TOTPStore) ReplacePending

func (s *TOTPStore) ReplacePending(ctx context.Context, cred *totp.Credential) error

ReplacePending is EnrollPending without the active-credential guard: it stores cred as userID's pending enrollment whatever else is on file, and leaves any active credential completely alone, so codes keep validating against the old factor until a later ConfirmEnrollment promotes this one.

It takes the user's lock all the same. It has no guard of its own to protect, but DeleteTOTP's promise ("the user ends up with nothing") is only worth anything if no other write to either slot can land between its two deletes.

func (*TOTPStore) SaveTOTP

func (s *TOTPStore) SaveTOTP(ctx context.Context, cred *totp.Credential) error

SaveTOTP persists an update to an existing active credential — in practice Validate's post-check LastUsedCounter bump — as a single conditional upsert:

INSERT INTO totp_active (...) VALUES (...)
    ON CONFLICT (user_id) DO UPDATE SET ...
 WHERE totp_active.id <> excluded.id
    OR excluded.last_used_counter >= totp_active.last_used_counter

The WHERE on DO UPDATE is the monotonicity guard: a save that would lower the counter for the active credential with the same ID matches nothing, changes nothing, and is reported as ErrTOTPCounterRegressed rather than applied. Of two racing validations only one advances the clock, and the loser cannot rewind it.

It runs under the user's lock like every other write here. PostgreSQL would very likely be enough on its own — ON CONFLICT DO UPDATE re-checks its WHERE against the row version a concurrent writer committed — but a replay-counter guard is the last place to rest on "very likely", and the lock is already the package's answer everywhere else.

type TokenStore

type TokenStore struct {
	// contains filtered or unexported fields
}

TokenStore is the PostgreSQL sulis.TokenStore.

Everything else rests on ConsumeToken's atomicity, which is why that method is the only one here with a transaction: a store that reads the row, checks used, and then writes hands two callers presenting the same password-reset link at the same instant one working reset each.

func (*TokenStore) ConsumeToken

func (s *TokenStore) ConsumeToken(ctx context.Context, hash string, purpose sulis.TokenPurpose) (*sulis.Token, error)

ConsumeToken finds the unused token matching hash AND purpose and marks it used in one statement, returning it:

UPDATE tokens SET used = true
 WHERE token_hash = $1 AND purpose = $2 AND used = false
RETURNING ...

Purpose is part of the lookup key rather than something checked afterwards, so a two-factor token presented to the password-reset flow matches nothing at all — and, because the statement never touches it, that mismatched attempt consumes nothing either.

Two callers presenting the same token at the same instant are separated by the row lock this UPDATE takes: the second blocks, and when it resumes READ COMMITTED re-evaluates "used = false" against the committed row, which now fails. Exactly one caller ever sees the RETURNING row.

The statement returning no row means one of two things the caller maps to different outcomes, so a follow-up existence check in the same transaction tells them apart: sulis.ErrTokenAlreadyUsed when the token is there but spent, sulis.ErrTokenNotFound when hash+purpose matches nothing.

Expiry is deliberately not part of the predicate. The contract makes this method's job "find the unused token and mark it used"; sulis compares ExpiresAt itself, on the token this returns, and wants the token burned either way.

func (*TokenStore) CreateToken

func (s *TokenStore) CreateToken(ctx context.Context, token *sulis.Token) error

CreateToken inserts token.

func (*TokenStore) DeleteExpiredTokens

func (s *TokenStore) DeleteExpiredTokens(ctx context.Context) error

DeleteExpiredTokens removes every token whose expiry has passed, spent or not.

func (*TokenStore) DeleteUserTokens

func (s *TokenStore) DeleteUserTokens(ctx context.Context, userID string, purpose sulis.TokenPurpose) error

DeleteUserTokens removes every token for the given user and purpose — the "invalidate the outstanding reset links" primitive. Deleting zero tokens is not an error.

type UserStore

type UserStore struct {
	// contains filtered or unexported fields
}

UserStore is the PostgreSQL sulis.UserStore.

The two requirements the interface places on a real implementation are both in the schema rather than in this file: the UNIQUE index on lower(email) is what makes two accounts racing to claim one address resolve to exactly one winner, and the version column is what lets UpdateUser express its compare-and-swap as a single statement. Neither can be moved up into Go code without reintroducing the race it exists to close.

Nothing here takes a lock this package chose. Both races are between two writers of ONE row (or of one index entry), and PostgreSQL's READ COMMITTED isolation already makes the loser of such a race re-evaluate its WHERE clause against what the winner committed — so the loser's UPDATE matches zero rows, or its INSERT is refused by the index, without any help.

func (*UserStore) CreateUser

func (s *UserStore) CreateUser(ctx context.Context, user *sulis.User) error

CreateUser inserts user. An address another user already holds — case insensitively, see the schema — or an ID already in use, which would lose an account if it silently overwrote, comes back from PostgreSQL as SQLSTATE 23505 and is reported as sulis.ErrUserAlreadyExists.

The row starts at version 0 whatever user.Version says: the version column is the store's, set on read and passed back unchanged to UpdateUser, and a new row has no prior write to be stale against.

func (*UserStore) DeleteUser

func (s *UserStore) DeleteUser(ctx context.Context, id string) error

DeleteUser removes the user with the given ID. Deleting a user who is not there is not an error: the caller's intent is already satisfied.

func (*UserStore) GetUserByEmail

func (s *UserStore) GetUserByEmail(ctx context.Context, email string) (*sulis.User, error)

GetUserByEmail returns the user whose live address is email, or sulis.ErrUserNotFound.

The comparison is lower(email) = lower($1), which is both the case-insensitive match the SQLite sibling's COLLATE NOCASE column gives and the exact expression the unique index is built on — so this uses the index rather than sequentially scanning the table, which a plain "email = $1" against a functionally-indexed column would quietly do. sulis normalizes an address to lowercase long before a store sees it, so this only ever differs from an exact match for a caller that skipped normalization, and there it fails safe.

func (*UserStore) GetUserByID

func (s *UserStore) GetUserByID(ctx context.Context, id string) (*sulis.User, error)

GetUserByID returns the user with the given ID, or sulis.ErrUserNotFound.

func (*UserStore) UpdateUser

func (s *UserStore) UpdateUser(ctx context.Context, user *sulis.User) error

UpdateUser applies user only while the stored row's version still matches the one the caller read, and only if the address it carries is not another user's:

UPDATE users SET ..., version = version + 1
 WHERE id = $1 AND version = $2

Zero rows affected means either that no such user exists or that another writer already advanced the version, and the two are different errors to the caller, so a follow-up existence check inside the same transaction tells them apart: sulis.ErrUserNotFound or sulis.ErrConcurrentUpdate. A unique violation on the address is sulis.ErrUserAlreadyExists.

Under READ COMMITTED a second writer blocks on the row the first is updating and then re-evaluates this WHERE clause against the committed result, so the version predicate is what rejects it — not the order the two arrived in.

The version predicate is checked first by construction. A write that is both stale and colliding therefore reports the staleness, which is the more useful of the two: the caller must re-read either way, and the collision it would have hit was computed from a row it no longer has.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL