Documentation
¶
Overview ¶
Package database stores session records in a SQL table.
Reach for it when losing the cache must not sign everybody out, or when a sign-out has to be enforceable rather than very nearly enforceable. Otherwise sessions/cache is cheaper and does the same job.
backend, _ := sessionsdatabase.NewBackend[Principal](
&sessionsdatabase.Config{TablePrefix: "ddb"}, db,
sessionsdatabase.WithSweeper(ctx, 5*time.Minute),
)
store, _ := sessions.NewStore(backend)
The dialect is taken from the database.Client rather than configured. A configured dialect that disagrees with the client it is paired with produces syntactically valid SQL the server rejects at runtime, and there is no way to notice before it does.
What the table buys ¶
Two operations are single statements here and approximations in the cache backend, and both matter for the same reason — a request that read a session just before it ended must not be able to write it back afterwards:
Update is one UPDATE with a WHERE clause. A row that has been deleted is not recreated, so a sign-out that has committed stays committed.
Rename runs its DELETE and INSERT in one transaction. Either the old identifier stops resolving and the new one starts, or neither does. There is no interval in which both work, which is the interval a session-fixation attack needs.
Sweeping ¶
A cache reclaims its own expired entries; a table does not. Without a sweep this one grows with every session ever created — the rows are unusable long before they are removed, since the store decides expiry from the record's own anchors, but they are still there.
WithSweeper runs it in-process on a timer. In a fleet that is one sweeper per replica doing the same delete; a scheduler calling Sweep once is the better shape, and the reason Sweep is exported.
The schema ¶
The DDL lives in the migrations subpackage, rendered for a dialect and a table prefix. It is not shipped as a numbered migration file — those are numbered globally per consumer, so a platform-owned number would collide with theirs.
expires_at exists for the sweeper alone and appears in no read predicate. Which sessions are live is decided above this layer, from created_at and last_seen_at against the store's policy, so that both backends answer the question identically — and so that clock skew between a writer and a reader cannot hide a live session.
scope and principal are who holds the session, and they are what makes this the only backend that can answer "which sessions does this person hold, and end the ones that are not this one". The pair is indexed together and every statement in that surface binds both: a revocation missing the scope reaches every tenant that spells an identifier the same way, and one missing the principal signs out everybody in the tenant. Neither has a column default, and scope's absence is the deliberate one — the empty string is tenancy.Global(), so a default would hand the global scope to a write that forgot the column.
The device metadata sits beside them and appears in no UPDATE either, for created_at's reason one step out: it describes the moment a session was established, and a recorded device that moved under a user would describe nothing at all. A renewal carries both across by writing a fresh row from the record it read.
created_at appears in no UPDATE. It anchors the absolute timeout, and leaving it out of the statement makes "an update never extends a session's total lifetime" structural rather than a rule somebody has to remember.
Where the SQL comes from ¶
Nothing in this package composes a statement. The six it executes — the create, the read, the overwrite, the existence check, the delete, and the sweep — are rendered from sessions/database/internal/queries into one canonical .sql per dialect, checked against this package's own schema by sqlc with no database running, and executed through the querier sqlc-gen-unison generates from those same files. A column renamed in migrations is then a failed `make unison` rather than a runtime scan error on whichever dialect noticed first.
The rendered .sql is committed even though nothing imports it, for the reasons identity's is: it is what `sqlc compile` is handed, it anchors the drift gate a test states byte for byte against the renderer, and it is what a reviewer reads when they want to see a statement in the spelling its arguments still have names in. `make generate` writes it; `make unison` renders the per-dialect schema beside it and runs the emitter over the pair.
The sweep is the one statement worth stopping at, because it is the only one here whose predicate is not an equality and the only one that reads a clock. Its deadline is bound rather than read off the server: expires_at is stamped as now-plus-a-TTL from the clock this backend was constructed with — the interface hands this layer a duration, not an instant — so a comparison against the server's CURRENT_TIMESTAMP would be two clocks deciding one row, and under the injected clock a test controls the two are years apart. See querygen.AtMostArgument.
One consequence of the tier is visible on SQLite and nowhere else. That engine has no date type, so a timestamp is text, and the generated querier binds one in the shape the engine's own CURRENT_TIMESTAMP writes — whole seconds. A session's three stamps are therefore stored truncated to the second there, which moves every deadline computed from them at most one second earlier. Earlier is the safe direction, and the other two dialects store what they are given.
Encoding ¶
Payloads go into the data column through an encoding.Codec, CBOR by default. A session with no payload stores NULL and reads back as nil rather than as a zero value.
Rows written with one encoding are unreadable through another and carry no record of which wrote them, so changing WithCodec on a deployed store signs everybody out. Record.Version cannot soften that: it is a column, not part of the blob, so it catches a changed payload shape and not a changed encoding.
Index ¶
- Constants
- Variables
- type Backend
- func (b *Backend[T]) Close() error
- func (b *Backend[T]) Create(ctx context.Context, id string, record *sessions.Record[T], ttl time.Duration) error
- func (b *Backend[T]) Delete(ctx context.Context, id string) error
- func (b *Backend[T]) DeleteAllHeld(ctx context.Context, holder sessions.Holder, keepID string) (int, error)
- func (b *Backend[T]) DeleteHeld(ctx context.Context, holder sessions.Holder, id string) (int, error)
- func (b *Backend[T]) ListHeld(ctx context.Context, holder sessions.Holder) ([]*sessions.Identified[T], error)
- func (b *Backend[T]) Load(ctx context.Context, id string) (*sessions.Record[T], error)
- func (b *Backend[T]) Rename(ctx context.Context, oldID, newID string, record *sessions.Record[T], ...) error
- func (b *Backend[T]) Sweep(ctx context.Context) (int64, error)
- func (b *Backend[T]) Update(ctx context.Context, id string, record *sessions.Record[T], ttl time.Duration) error
- type Config
- type Option
- func WithClock(c clock.Clock) Option
- func WithCodec(codec encoding.Codec) Option
- func WithLogger(logger logging.Logger) Option
- func WithMetricsProvider(metricsProvider metrics.Provider) Option
- func WithSweeper(ctx context.Context, interval time.Duration) Option
- func WithTracerProvider(tracerProvider tracing.Provider) Option
Constants ¶
const DefaultPayloadContentType = encoding.ContentTypeCBOR
DefaultPayloadContentType is what session payloads are encoded as when no codec is supplied: CBOR, which is compact, binary, and readable outside Go.
const DefaultTablePrefix = ""
DefaultTablePrefix is the namespace the session table carries when none is configured, which is none — rendering plain "sessions".
The sessions segment is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_sessions, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.
Variables ¶
var ErrNilClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil session database client")
ErrNilClient indicates NewBackend was called without a database client. It wraps errors.ErrNilInputParameter, so a caller may check either.
Functions ¶
This section is empty.
Types ¶
type Backend ¶
type Backend[T any] struct { // contains filtered or unexported fields }
Backend stores session records in a SQL table.
It is a concrete type rather than an interface because it does one thing more than sessions.Backend describes: Sweep removes rows whose deadlines have passed. A cache expires its own entries and needs no equivalent, so the method does not belong on the interface — but a caller who chose this backend has to be able to reach it.
func NewBackend ¶
NewBackend builds a Backend over a database client.
Reads go through the write pool, deliberately. A session is written and then read on the very next request, and replica lag turns that into a sign-in that did not take — the one failure a user retries by signing in again, producing another session that also appears not to exist. Session rows are small, single-key, and short-lived; they are not the reads worth scaling out.
func (*Backend[T]) Create ¶
func (b *Backend[T]) Create( ctx context.Context, id string, record *sessions.Record[T], ttl time.Duration, ) error
Create inserts a session row.
func (*Backend[T]) Delete ¶
Delete removes the row stored under id. A row that was already gone is not an error.
func (*Backend[T]) DeleteAllHeld ¶
func (b *Backend[T]) DeleteAllHeld(ctx context.Context, holder sessions.Holder, keepID string) (int, error)
DeleteAllHeld removes every session the holder holds, sparing keepID when it is not empty, and reports how many went.
One statement, not a listed set of identifiers deleted afterwards. A session established between the list and the deletes is a session the sign-out missed — which is the interval an attacker who still holds a valid session uses to re-establish one.
func (*Backend[T]) DeleteHeld ¶
func (b *Backend[T]) DeleteHeld(ctx context.Context, holder sessions.Holder, id string) (int, error)
DeleteHeld removes one of the holder's sessions, reporting how many rows went.
The holder is in the WHERE clause rather than checked first. A revocation that reads the row, decides it belongs to the caller, and then deletes it by identifier is a revocation whose two halves can be got out of step; here the server decides "this session, and it is theirs" at the instant the row goes, and the count is what it decided.
func (*Backend[T]) ListHeld ¶
func (b *Backend[T]) ListHeld(ctx context.Context, holder sessions.Holder) ([]*sessions.Identified[T], error)
ListHeld returns every record the holder holds, newest first.
It applies no expiry, deliberately: the deadline is decided one layer up from the record's own anchors against the store's clock, and a predicate on the stored expires_at here would be a second clock deciding which sessions a person is shown. A row this read returned and the store then filtered out is a row Get would have refused too, which is the property that matters.
It is unpaged, because the set is a person's sessions and a "sign out everywhere" that acted on one page of them would not be one.
func (*Backend[T]) Rename ¶
func (b *Backend[T]) Rename( ctx context.Context, oldID, newID string, record *sessions.Record[T], ttl time.Duration, ) error
Rename moves a record from oldID to newID inside one transaction.
This is the operation the database backend exists for. Either the old identifier stops resolving and the new one starts, or neither happens; there is no interval in which both work, which is the interval a fixation attack needs.
func (*Backend[T]) Sweep ¶
Sweep removes every row whose deadline has passed, reporting how many it removed.
It is not what makes a session expire — the store decides that from the record's own anchors, so a row this has not reached yet is already unusable. What it does is stop the table growing with every session ever created.
One statement, no batching. Session rows are small and the index on expires_at makes the delete proportional to what is actually dead rather than to the table; a fleet that outgrows that wants a scheduled sweep with its own batching rather than a bigger one here.
func (*Backend[T]) Update ¶
func (b *Backend[T]) Update( ctx context.Context, id string, record *sessions.Record[T], ttl time.Duration, ) error
Update overwrites an existing session row.
The WHERE clause is the whole guarantee: a row that has been deleted is not recreated, so a request that read a session immediately before it was signed out cannot write it back afterwards. That is a single statement here and only an approximation in the cache backend.
type Config ¶
type Config struct {
// TablePrefix is the namespace prepended to the session table's name. Empty
// renders the schema's own name, "sessions"; set it to share a database
// between applications, which renders e.g. ddb_sessions. It must not end in
// '_' — the separator is supplied for you.
TablePrefix string `env:"TABLE_PREFIX" json:"tablePrefix,omitempty" yaml:"tablePrefix,omitempty"`
// contains filtered or unexported fields
}
Config configures a Backend.
It carries no dialect. The dialect comes from the database.Client, which is the only place it can come from and be right: a configured dialect that disagrees with the client it is paired with produces syntactically valid SQL the server rejects at runtime.
func (*Config) ValidateWithContext ¶
ValidateWithContext validates a Config struct.
The prefix is vetted against the schema rather than against a pattern, because a prefix that is a legal identifier on its own can still push the index name past what the supported engines accept — and that failure would otherwise surface as a migration that half ran.
type Option ¶
type Option func(*options)
Option configures a Backend at construction.
It carries no type parameter even though NewBackend does: Go cannot infer a type argument from a call's result type, so an Option[T] would force every call site to spell the payload type out by hand forever.
func WithClock ¶
WithClock swaps the clock the expires_at column is stamped from, and the one the sweeper ticks on.
func WithCodec ¶
WithCodec sets how session payloads are encoded into the data column.
Rows written with one encoding are unreadable through another, and a stored row carries no record of which wrote it. Changing this on a deployed store therefore signs everybody out — the payloads decode to nothing, which Record.Version cannot help with because the version is a column rather than part of the blob. Choose it once.
func WithLogger ¶
WithLogger attaches a logger. An absent logger logs nowhere.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider for the sweeper's counters. An absent one records nothing.
func WithSweeper ¶
WithSweeper starts a background sweep that removes rows whose deadlines have passed, every interval, until ctx is done.
Unlike a cache, a table does not reclaim its own expired rows, and without a sweep this one grows with every session ever created. Running it is not optional in any long-lived deployment; what is optional is running it here rather than from a scheduler that calls Sweep, which is the better answer for a fleet — one sweeper, not one per replica.
The context bounds the goroutine's life. Passing a nil context or a non-positive interval starts nothing.
func WithTracerProvider ¶
WithTracerProvider attaches a tracer provider. An absent one traces nowhere.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
queries
Package queries is the session schema described as data: the table's name, its columns in the order every statement lists them, the subset a write may assign, and the one column that may be NULL.
|
Package queries is the session schema described as data: the table's name, its columns in the order every statement lists them, the subset a write may assign, and the one column that may be NULL. |
|
queriesgen
command
Command queriesgen writes the canonical sqlc input for the session schema, one file per dialect, from sessions/database/internal/queries.
|
Command queriesgen writes the canonical sqlc input for the session schema, one file per dialect, from sessions/database/internal/queries. |
|
Package migrations supplies the session table's DDL, rendered for a dialect and table prefix.
|
Package migrations supplies the session table's DDL, rendered for a dialect and table prefix. |