session

package
v0.1.0-develop.2026060... Latest Latest
Warning

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

Go to latest
Published: Jun 8, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package session declares the typed-Go-heavy Protocol primitive that bundles session-related protocol decisions for accesscore (and any future cell that owns server-side session state).

This package is the protocol vocabulary plus a Store interface, an in- memory implementation (MemStore), and the Protocol-driven storetest conformance suite. The PG-backed Store implementation and the cell-side composition root wiring land in later phases of the same plan (docs/plans/202605082145-034-pg-corecell-b-route-plan.md, S3+S5 / S4).

MemStore scope (intentional design tradeoffs):

  • Dev / test only. The production session path is the PG Store landing in S3+S5; cells inject *Protocol + Store via composition root in S4.
  • No GC and no capacity bound. Expired sessions remain Get-able by design (ADR-Session D3 fail-closed: callers decide via Session fields, not by absence). PG store handles purge via janitor / TTL cron in S3+S5; the protocol vocabulary itself does not own GC.
  • O(n) RevokeForSubject. The mem implementation scans the session map under a single RWMutex; this matches the existing same-tier mem primitives (cells/accesscore/internal/mem/session_repo.go, runtime/auth/refresh/memstore) and is acceptable at dev/test subject counts. PG store delivers indexed revoke at scale via UPDATE ... WHERE user_id = $1.
  • No instrumentation (slog / metrics). Observability is a cell-layer responsibility per GoCell layering (cells/, not runtime/); accesscore wires slog around Store calls in S4.

The protocol decisions encoded here are governed by:

  • docs/architecture/202605101400-adr-credential-session-protocol.md (D1 jti-only token model / D2 AuthzEpoch ordering / D3 fail-closed credential events / D4 refresh-vs-session co-lifecycle / D5 same-tx revocation / D6 sealed FingerprintMode)
  • docs/architecture/202605101400-adr-admin-invariant.md (admin-related domain rules; orthogonal to this package)
  • docs/architecture/202605101200-adr-typed-go-heavy-protocol-primitives.md (the typed-Go-heavy paradigm this package instantiates)

Construction:

proto, err := session.NewProtocol(
    session.WithFingerprint(session.FingerprintJTIRef{}),
    session.WithOrdering(session.OrderingAuthzEpoch{}),
    session.WithRevokeOn(
        session.CredentialEventPasswordReset,
        session.CredentialEventLock,
        session.CredentialEventDelete,
        session.CredentialEventRoleRevoke,
    ),
)

Composition root only (cellmodules/accesscore/module.go):

proto, err := session.NewProtocol(...)

session.NewProtocol must only be called from cmd/* (or from this package's own storetest sub-package, which constructs the canonical test Protocol) — cells must consume an injected *Protocol, never construct their own. This boundary is enforced by archtest SESSION-PROTOCOL-COMPOSITION-ROOT-01 (active; cell consumers begin arriving in S4 of the plan above).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ValidateCredentialEvent

func ValidateCredentialEvent(e CredentialEvent) bool

ValidateCredentialEvent reports whether e is a declared CredentialEvent. Used to reject open-int values like CredentialEvent(99) at store boundaries.

Exported so that adapter-layer stores (e.g. adapters/postgres.PGSessionStore) can delegate to this single predicate instead of maintaining a parallel switch. Having one source of truth prevents silent divergence when a new CredentialEvent constant is added in the future.

Types

type CredentialEvent

type CredentialEvent int

CredentialEvent enumerates credential state changes that revoke active sessions and refresh chains for the affected subject (ADR D3 — fail-closed by default; permission removal routes through CredentialEventRoleRevoke).

const (
	// CredentialEventPasswordReset is emitted when a user's password is reset
	// (forced reset, self-service change, or operator-initiated).
	CredentialEventPasswordReset CredentialEvent = iota + 1
	// CredentialEventLock is emitted when an account transitions to a locked
	// state (manual lock or auto-lock from failed-login threshold).
	CredentialEventLock
	// CredentialEventDelete is emitted when a user is deleted.
	CredentialEventDelete
	// CredentialEventRoleRevoke is emitted when any role assignment changes
	// for the user (revoke, downgrade, or permission-set change).
	CredentialEventRoleRevoke
	// CredentialEventRefreshReuse is emitted when refresh-token reuse is
	// detected beyond the reuse-grace window. The credentialinvalidate funnel
	// passes this event to RevokeForSubject so the security response is
	// identical to a full credential revocation.
	//
	// This event is NOT part of allCredentialEvents (and therefore not required
	// by WithRevokeOnAll / NewProtocol's completeness check). It is a
	// security-response event triggered by the refresh-store's reuse detection
	// path, not a user-lifecycle credential-state transition.
	CredentialEventRefreshReuse
)

func (CredentialEvent) String

func (e CredentialEvent) String() string

String returns a stable textual label suitable for diagnostics, storetest case names, and slog attributes.

type FingerprintJTIRef

type FingerprintJTIRef struct{}

FingerprintJTIRef stores the JWT jti claim reference (RFC 9068 §2.2.4) on the server side. Session rows persist {sid, jti}; no token plaintext or HMAC fingerprint is stored. The authz_epoch_at_issue column was restored in S4d (migration 026 for sessions, migration 027 for refresh_tokens) as the row-level credential provenance source of truth; sessionvalidate compares the live users.authz_epoch against the session row's authz_epoch_at_issue, not against a JWT claim. See ADR 202605101400 §A8 (A1 RETRACTED).

type FingerprintMode

type FingerprintMode interface {
	// contains filtered or unexported methods
}

FingerprintMode is sealed: only types declared in this package may implement it (the marker method fingerprintModeOK is unexported). Callers select a concrete fingerprint shape at composition root via WithFingerprint.

Future opaque-token deployments may add a sibling type (e.g. FingerprintHMACSha256) without breaking the existing API; jti-only is the only shape supported today (see ADR D1).

type MemStore

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

MemStore is an in-memory Store implementation for dev and tests. It is not a production substrate — the PG-backed Store landing in S3+S5 owns the production path. Three properties follow from the dev/test scope and are documented design choices, not gaps:

  • Single RWMutex over a map[ID]*Session. RevokeForSubject scans under the write lock (O(n) in subject count). Acceptable at dev/test scale; PG handles batch revoke via SQL with indexed user_id.
  • No GC and no capacity ceiling. Expired sessions remain Get-able (ADR-Session D3); cleanup is a PG janitor concern.
  • No instrumentation. Observability is a cell-layer concern (S4 wires slog/metrics around Store calls in accesscore).

The contract test suite exercises concurrent access (`go test -race`) and the protocol decisions encoded in *Protocol drive RevokeForSubject scoping. See package doc for the full scope rationale.

func NewMemStore

func NewMemStore(protocol *Protocol, clk clock.Clock) (*MemStore, error)

NewMemStore constructs a MemStore. Both protocol and clk are strong- dependency wiring (they are not replaceable defaults); typed-nil and bare nil are rejected at construction so misconfiguration surfaces at startup rather than at the first request.

runtime-api.md §Option 范式分层 — one or two unconditional dependencies are passed positionally; Option pattern only becomes warranted at ≥ 3 deps or when an accumulator (e.g. WithRevokeOn) appears.

func (*MemStore) Create

func (m *MemStore) Create(_ context.Context, t tenant.TenantID, s *Session) error

Create persists s with the given tenant t. t must be a non-empty canonical UUID; empty TenantID returns ErrValidationFailed. Protocol-shape validation rejects records that violate the configured FingerprintMode (e.g. empty JTI under FingerprintJTIRef); duplicate IDs return ErrSessionConflict. Stored value is a defensive copy so caller mutations cannot bleed into the store.

func (*MemStore) Get

func (m *MemStore) Get(_ context.Context, id string) (*ValidateView, error)

Get returns the validate projection of the session keyed by id. Revoked sessions are still returned (caller inspects RevokedAt); GC eligibility (Session.ExpiresAt) is intentionally not exposed — validate paths must not gate on it.

func (*MemStore) RepoReady

func (m *MemStore) RepoReady(_ context.Context) error

RepoReady implements healthz.RepoProber. In-memory store is always ready — there is no external relation or schema that can go missing. Returns nil unconditionally (MemStore convention per kernel/healthz.RepoProber godoc).

func (*MemStore) Revoke

func (m *MemStore) Revoke(_ context.Context, id string) error

Revoke marks the session keyed by id dead. Idempotent: missing IDs and already-revoked sessions both succeed without modifying state. Once RevokedAt is set it is never re-stamped (append-only revoke semantics — ADR-Session D3 fail-closed by default).

func (*MemStore) RevokeForSubject

func (m *MemStore) RevokeForSubject(_ context.Context, subjectID string, event CredentialEvent, tok credentialfence.FenceToken) error

RevokeForSubject marks every active session belonging to subjectID dead. The CredentialEvent argument is informational under the current protocol (D3 fail-closed by default — every event triggers identical revoke scoping); future protocols may scope per-event when sealed alternatives are added. Missing subjects yield no error (always succeeds).

type Option

type Option func(*Protocol) error

Option mutates a Protocol during NewProtocol. Options are applied in order; each Option may return an error to short-circuit construction.

func WithFingerprint

func WithFingerprint(fp FingerprintMode) Option

WithFingerprint declares the token fingerprint mode.

Both bare-nil and typed-nil are rejected by NewProtocol so the fingerprint mode is never silently absent. Pattern mirrors runtime/http/router.WithRateLimiter (see runtime-api.md §Option 范式分层 — strong-dependency wiring option).

func WithOrdering

func WithOrdering(om OrderingModel) Option

WithOrdering declares the login-vs-revoke ordering primitive (ADR D2).

Both bare-nil and typed-nil are rejected by NewProtocol so the ordering model is never silently absent. Pattern mirrors runtime/http/router.WithRateLimiter (see runtime-api.md §Option 范式分层 — strong-dependency wiring option).

func WithRevokeOn

func WithRevokeOn(events ...CredentialEvent) Option

WithRevokeOn declares a set of credential events that revoke active sessions and refresh chains for the affected subject (ADR D3).

Each event must be a declared CredentialEvent constant; unknown values are rejected. NewProtocol additionally requires the complete set of 4 events to be declared (ADR D3 fail-closed) — prefer WithRevokeOnAll() over manual enumeration.

This is a builder-style option: multiple WithRevokeOn calls accumulate; duplicates are deduplicated, preserving the order of first occurrence. Calling WithRevokeOn() with zero events is a misuse and is rejected at construction (≥1 event required for fail-closed semantics).

func WithRevokeOnAll

func WithRevokeOnAll() Option

WithRevokeOnAll declares all 4 CredentialEvent values at once (ADR D3 fail-closed by default). Recommended path for composition roots — the typed enum + complete-set check make "forgot one event" unrepresentable.

type OrderingAuthzEpoch

type OrderingAuthzEpoch struct{}

OrderingAuthzEpoch uses a per-user monotonic epoch column to invalidate stale tokens (OAuth Security BCP §4.13.1). In the S4d row-provenance model (ADR 202605101400 §A8; §A1 RETRACTED), the access JWT does NOT carry an authz_epoch claim. Instead, sessionvalidate.enforceSessionState compares the live users.authz_epoch against the session row's authz_epoch_at_issue column (restored by migration 026/027). Inequality (`!=`) is the comparison: fail-closed against both stale grants (row_epoch < user_epoch, after a credential event) and tampered grants (row_epoch > user_epoch). Session rows are created with authz_epoch_at_issue = users.authz_epoch at login time; refresh_tokens rows inherit the epoch from the parent session/token row on Rotate. See ADR 202605101400 §A8 for the full row-provenance design.

type OrderingModel

type OrderingModel interface {
	// contains filtered or unexported methods
}

OrderingModel is sealed: only types declared in this package may implement it. Callers select a concrete ordering primitive at composition root via WithOrdering. Future alternatives (advisory lock / row version) may be added as sibling types if a use case emerges.

type Protocol

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

Protocol bundles the protocol decisions that govern a session subsystem.

Fields are required (NewProtocol fail-fasts on missing values) and are immutable after construction. Accessor methods return defensive copies where applicable.

func NewProtocol

func NewProtocol(opts ...Option) (*Protocol, error)

NewProtocol assembles a Protocol from the supplied options and fail-fasts on missing required fields. The returned *Protocol is safe for concurrent read-only use.

func (*Protocol) Fingerprint

func (p *Protocol) Fingerprint() FingerprintMode

Fingerprint returns the configured fingerprint mode.

func (*Protocol) Ordering

func (p *Protocol) Ordering() OrderingModel

Ordering returns the configured ordering model.

func (*Protocol) RevokeOn

func (p *Protocol) RevokeOn() []CredentialEvent

RevokeOn returns a defensive copy of the configured credential events. Callers must not assume the returned slice retains its underlying array.

type Session

type Session struct {
	// ID is the opaque server-side session identifier. Storage backends pick
	// the format (UUID, ULID, etc.); the protocol does not interpret it.
	ID string

	// SubjectID is the user identifier that owns this session. The protocol
	// treats this as an opaque string; callers may use any non-empty format
	// (UUID, ULID, etc.).
	//
	// Backends MAY enforce additional shape constraints. For example, the
	// PG-backed store (adapters/postgres.PGSessionStore) requires SubjectID
	// to be a UUID string for FK relationships to the users.id column.
	// Mem store accepts any non-empty string.
	SubjectID string

	// JTI is a unique fingerprint stored on the session row; backends with
	// FingerprintJTIRef require it non-empty on Create. It stores the access
	// JWT's `jti` claim per RFC 9068 §2.2.4 — sessionmint.MintAccess generates
	// a fresh UUIDv4 per token and returns it as Result.JTI so the caller
	// (sessionlogin) can persist it on the session row at Create. Refresh
	// keeps session.ID stable across rotations but each minted access token
	// gets its own jti; the session row stores the *original* login-time jti
	// as the fingerprint, not the latest rotation.
	JTI string

	// AuthzEpochAtIssue is the snapshot of users.authz_epoch at the moment
	// this session was created. It is the credential provenance source of
	// truth — sessionvalidate compares it against the current users.authz_epoch
	// (NOT against the JWT claim, which can be re-minted from live user state
	// during refresh). A zero value is invalid: Store.Create rejects it with
	// ErrValidationFailed (storetest conformance T-S4D-1 enforces).
	//
	// ADR-credential §A8 (S4d) — supersedes §A1 (retracted): row-level pin is
	// not a JWT claim mirror, it is the only server-side source that survives
	// refresh rotation. Without it, a stale refresh upgrades to live epoch on
	// next use (PR #490 review P1).
	//
	// ref: PostgreSQL row-level locking — login flow uses SELECT ... FOR UPDATE
	// on users to serialize against credentialinvalidate.Invalidator.Apply,
	// making the snapshot read+write atomic with respect to epoch bumps.
	AuthzEpochAtIssue int64

	// CreatedAt is the issue timestamp in UTC.
	CreatedAt time.Time

	// ExpiresAt is the GC eligibility timestamp in UTC — when the row may be
	// physically deleted by a sweep (migration 018 idx_sessions_expires).
	// It is NOT a validate gate: Store.Get returns a *ValidateView that does
	// not expose this field, so validate paths cannot reach it. JWT exp claim
	// guards access-token lifetime; RevokedAt guards revocation.
	//
	// ref: ory/fosite handler/oauth2/strategy_jwt.go ValidateAccessToken
	// (JWT exp only); hashicorp/vault expiration.go (leaseEntry.ExpireTime
	// physically isolated from token lookup path).
	ExpiresAt time.Time

	// RevokedAt is non-nil iff Revoke / RevokeForSubject has marked this row
	// dead. Once set it must never be cleared (append-only revoke semantics
	// — ADR-Session D3 fail-closed).
	RevokedAt *time.Time

	// TenantID is the tenant that owns this session. It is the carrier for
	// the sessions.tenant_id column (migration 054) and is required at
	// Create time — the PG composite FK (tenant_id, subject_id) → users
	// proves that session.tenant_id is trustworthy for deriving RLS scope
	// in refresh/validate paths. Empty TenantID is rejected by Store.Create.
	TenantID tenant.TenantID
}

Session is the canonical server-side session record exchanged between session.Store implementations and consumers. Field shape is fixed by ADR-Session (`docs/architecture/202605101400-adr-credential-session-protocol.md`) D1/D2 — jti-only fingerprint reference, AuthzEpoch ordering snapshot, no access-token plaintext.

Session is a value record; behavior (revoked/expired predicates) is left to call sites until ≥ 3 distinct call sites emerge (then we can extract methods — go-standards.md repetition rule).

type Store

type Store interface {
	// Create persists s with the given tenant t. t must be a valid non-empty
	// canonical UUID; empty TenantID returns ErrValidationFailed. t is stored
	// on the sessions.tenant_id column (migration 054) and exposed through
	// ValidateView.TenantID for downstream RLS scope derivation.
	Create(ctx context.Context, t tenant.TenantID, s *Session) error
	Get(ctx context.Context, id string) (*ValidateView, error)
	Revoke(ctx context.Context, id string) error
	RevokeForSubject(ctx context.Context, subjectID string, event CredentialEvent, tok credentialfence.FenceToken) error
	// RepoReady is a differentiated readiness check for the sessions relation.
	// See Store godoc for full semantics.
	RepoReady(ctx context.Context) error
}

Store persists session records and exposes a differentiated repository readiness check. Implementations must obey the protocol decisions encoded in *Protocol — Create rejects records that violate FingerprintMode shape (e.g. empty JTI under FingerprintJTIRef), and RevokeForSubject revokes every active session for the subject regardless of which CredentialEvent triggered it (D3 fail-closed by default).

Method semantics (ADR-Session §4.2):

  • Create: persist a new session. Nil session, empty Session.ID, empty Session.SubjectID, or zero Session.AuthzEpochAtIssue return ErrValidationFailed (S4d: epoch is required credential provenance — storetest conformance T-S4D-1 fixes the contract). Records violating the protocol-configured FingerprintMode (e.g. empty JTI under FingerprintJTIRef) return ErrValidationFailed. Duplicate Session.ID returns ErrSessionConflict; the protocol does not mandate uniqueness on (SubjectID, JTI) — that is a backend decision (PG schema in S3+S5).
  • Get: fetch validate projection by Session.ID. Missing → ErrSessionNotFound; revoked sessions are still returned (caller checks RevokedAt). Session.ExpiresAt (GC eligibility) is intentionally not exposed — validate paths must not gate on it.
  • Revoke: mark a single session dead. Idempotent: already-revoked or missing IDs are no-ops returning nil (防枚举 — must not leak existence). RevokedAt is set exactly once; subsequent Revoke calls do not re-stamp. Decorating implementations MAY narrow this with an ambient-transaction precondition: adapters/redis.CachingSessionStore registers a post-commit cache-eviction hook (#796) and therefore panics if Revoke runs outside a RunInTx (after-commit) scope; the bare PG / mem stores impose no such precondition. Callers obtaining a Store from composition must honor the strictest wrapped implementation's scope (sessionlogout's persistRevoke already wraps Revoke in RunInTx); bare callers — the storetest conformance suite — supply a unit-of-work scope themselves. Compile- enforcing this precondition (a typed tx-scoped revoke capability rather than a runtime panic) was evaluated as gh #1615's F3 and resolved won't-do (a same-package seal is unreachable — adapters/redis's read path needs a synchronous cache mutation on the same field Revoke uses, and Go has no per-method field scoping; a cross-package seal is disproportionate with no industry precedent). The "second caller outside a tx" risk is instead caught statically by archtest SESSION-REVOKE-CALLER-INTX-01, which allowlists session.Store.Revoke's production callers (each RunInTx-wrapped).
  • RevokeForSubject: mark every active session for SubjectID dead. tok is a credentialfence.FenceToken — a sealed capability proof minted only by credentialinvalidate.Invalidator via credentialfence.Mint; passing nil panics via MustHave (B-class programmer error, surfaces as 500). Empty subjectID returns ErrValidationFailed; an event value not declared in the CredentialEvent enum returns ErrValidationFailed. With valid arguments, returns nil even when the subject has no sessions; pre- revoked sessions for the subject preserve their original RevokedAt timestamp (append-only revoke per ADR-Session D3).
  • RepoReady: differentiated readiness check for the sessions relation. SQL-backed implementations issue a representative query against the sessions table (e.g. SELECT 1 FROM sessions WHERE false) so the check exercises a distinct failure domain from the pool-level postgres_ready probe — it surfaces schema/migration drift and table-level permission loss that a connection ping cannot detect. In-memory implementations return nil (always ready, MemStore convention). Satisfies kernel/healthz.RepoProber; registered via cellgen RegisterReadiness.

type ValidateView

type ValidateView struct {
	ID        string
	SubjectID string
	RevokedAt *time.Time
	// AuthzEpochAtIssue exposes Session.AuthzEpochAtIssue to the validate
	// path. sessionvalidate compares this with the live users.authz_epoch;
	// mismatch → 401 (ADR-credential §A8). The JWT's authz_epoch claim is
	// removed in S4d — view.AuthzEpochAtIssue is the only credential
	// provenance source-of-truth.
	AuthzEpochAtIssue int64

	// TenantID exposes Session.TenantID to the validate / refresh paths.
	// sessionrefresh uses this as the scope for the accesscore RLS SET LOCAL
	// (PR-3b): the composite FK (tenant_id, subject_id) → users in the DB
	// guarantees this value is consistent with the user row. sessionvalidate
	// uses it to call GetByIDInTenant on the scoped tx path.
	TenantID tenant.TenantID
}

ValidateView is the narrow projection of a Session exposed by Store.Get. It carries exactly the fields validate paths (sessionvalidate, sessionrefresh, sessionlogout) need to make their decision: identity (ID, SubjectID) and revocation (RevokedAt). Session.ExpiresAt is intentionally absent — it is GC eligibility metadata, not a validate gate (see Session.ExpiresAt godoc).

This type-level partition mirrors hashicorp/vault's barrier-view isolation: token lookup paths physically cannot reach leaseEntry.ExpireTime, so the "validate by time comparison" anti-pattern is unrepresentable. Here, the equivalent guard is at the Go type level — sess.ExpiresAt is not a field on ValidateView, so re-introducing the double-gate fails to compile.

Directories

Path Synopsis
Package sessiontest provides test helpers for code that depends on runtime/auth/session.
Package sessiontest provides test helpers for code that depends on runtime/auth/session.
Package storetest provides a reusable Protocol-driven contract test suite for session.Store implementations.
Package storetest provides a reusable Protocol-driven contract test suite for session.Store implementations.

Jump to

Keyboard shortcuts

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