Files
threshold/internal/session/repository_session.go
T
Timothy Messier 29cd7bc9d6 fix(session): Move encryption/decryption out of database transactions (#4803)
Several session.Repository methods need to encrypt or decrypt fields
when persisting or retrieving a Session. These encrypt/decrypt
operations where being performed while within a database transaction.

The first step in the encrypt/decrypt operation is to retrieve the
kms.Wrapper. While the wrappers get cached in memory, the retrieval
still results in a database query to check that the kms collection
version has not changed. This query happens with a separate db.Reader,
and therefore uses a separate database connection from the transaction
being used by the session.Repository.

If the controllers are configured with a relatively low
`max_open_connections`, and be under sufficient load interactive with
sessions, the controller can consume all of the available database
connections, and get stuck with open transactions while waiting for a
database connection to execute the kms related queries. This can be see
by checking pg_stat_activity and seeing transactions stuck in `idle in
transaction` with a wait_event of ClientRead.

This commit changes all of the Repository methods the perform any
encryption or decryption operations, including the retrieval of the
kms.Wrapper, outside of a database transaction. An alternative approach
would be to unsure that the kms.Wrapper retrieval happens within the
same database transaction. However, it seems preferable to avoid doing
these relatively expensive operations within a transaction when it is
not necessary.

As part of this fix, it also eliminates some logic that is no longer
needed. During previous would with KMS wrapping, the method of how
sessions were encrypted was changed, and some logic to lazily re-encrypt
sessions was introduced. However, given that terminated sessions are
deleted, and the KMS changes was introduced several versions ago
(0.13.0), this lazy re-encrypt logic should no longer be necessary.

Refs: edd323b73a
See:
    https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW
    https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT
    https://developer.hashicorp.com/boundary/docs/configuration/controller#max_open_connections
2024-05-16 08:45:45 -04:00

989 lines
35 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package session
import (
"context"
"crypto/subtle"
"database/sql"
"fmt"
"strings"
"time"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/boundary/internal/db/timestamp"
"github.com/hashicorp/boundary/internal/errors"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/kms"
"github.com/hashicorp/boundary/internal/util"
wrapping "github.com/hashicorp/go-kms-wrapping/v2"
)
// CreateSession inserts into the repository and returns the new Session with
// its State of "Pending". The following fields must be empty when creating a
// session: WorkerId, and PublicId. No options are
// currently supported.
func (r *Repository) CreateSession(ctx context.Context, sessionWrapper wrapping.Wrapper, newSession *Session, workerAddresses []string, _ ...Option) (*Session, error) {
const op = "session.(Repository).CreateSession"
if newSession == nil {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing session")
}
if newSession.PublicId != "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "public id is not empty")
}
if len(newSession.Certificate) != 0 {
return nil, errors.New(ctx, errors.InvalidParameter, op, "certificate is not empty")
}
if newSession.TargetId == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing target id")
}
if newSession.UserId == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing user id")
}
if newSession.AuthTokenId == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth token id")
}
if newSession.ProjectId == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing project id")
}
if newSession.HostId == "" && newSession.HostSetId == "" && newSession.Endpoint == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing host source and endpoint")
}
if newSession.CtTofuToken != nil {
return nil, errors.New(ctx, errors.InvalidParameter, op, "ct is not empty")
}
if newSession.TofuToken != nil {
return nil, errors.New(ctx, errors.InvalidParameter, op, "tofu token is not empty")
}
if newSession.ExpirationTime == nil || newSession.ExpirationTime.Timestamp.AsTime().IsZero() {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing expiration time")
}
if len(workerAddresses) == 0 {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing addresses")
}
id, err := newId(ctx)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
privKey, certBytes, err := newCert(ctx, id, workerAddresses, newSession.ExpirationTime.Timestamp.AsTime(), r.randomReader)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
newSession.Certificate = certBytes
newSession.CertificatePrivateKey = privKey
newSession.PublicId = id
if err := newSession.encrypt(ctx, sessionWrapper); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to encrypt session"))
}
var returnedSession *Session
_, err = r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(read db.Reader, w db.Writer) error {
returnedSession = newSession.Clone().(*Session)
returnedSession.DynamicCredentials = nil
returnedSession.StaticCredentials = nil
if err = w.Create(ctx, returnedSession); err != nil {
return errors.Wrap(ctx, err, op)
}
if newSession.HostSetId != "" && newSession.HostId != "" {
hs, err := NewSessionHostSetHost(ctx, newSession.PublicId, newSession.HostSetId, newSession.HostId)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if err = w.Create(ctx, hs); err != nil {
return errors.Wrap(ctx, err, op)
}
returnedSession.HostSetId = hs.HostSetId
returnedSession.HostId = hs.HostId
} else if newSession.Endpoint != "" {
ta, err := NewSessionTargetAddress(ctx, newSession.PublicId, newSession.TargetId)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if err = w.Create(ctx, ta); err != nil {
return errors.Wrap(ctx, err, op)
}
}
if newSession.ProtocolWorkerId != "" {
swp, err := NewSessionWorkerProtocol(ctx, newSession.PublicId, newSession.ProtocolWorkerId)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if err = w.Create(ctx, swp); err != nil {
return errors.Wrap(ctx, err, op)
}
returnedSession.ProtocolWorkerId = swp.WorkerId
}
for _, cred := range newSession.DynamicCredentials {
cred.SessionId = newSession.PublicId
}
var staticCreds []any
for _, cred := range newSession.StaticCredentials {
cred.SessionId = newSession.PublicId
staticCreds = append(staticCreds, cred)
}
if len(staticCreds) > 0 {
if err = w.CreateItems(ctx, staticCreds); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("failed to create static credentials"))
}
// Get static creds back from the db for return
var c []*StaticCredential
if err := read.SearchWhere(ctx, &c, "session_id = ?", []any{newSession.PublicId}); err != nil {
return errors.Wrap(ctx, err, op)
}
returnedSession.StaticCredentials = c
}
// TODO: after upgrading to gorm v2 this batch insert can be replaced, since gorm v2 supports batch inserts
q, batchInsertArgs, err := batchInsertSessionCredentialDynamic(newSession.DynamicCredentials)
if err == nil {
rows, err := w.Query(ctx, q, batchInsertArgs)
if err != nil {
return errors.Wrap(ctx, err, op)
}
defer rows.Close()
for rows.Next() {
var returnedCred DynamicCredential
if err := w.ScanRows(ctx, rows, &returnedCred); err != nil {
return errors.Wrap(ctx, err, op)
}
returnedSession.DynamicCredentials = append(returnedSession.DynamicCredentials, &returnedCred)
}
if err := rows.Err(); err != nil {
return errors.Wrap(ctx, err, op)
}
}
var foundStates []*State
// trigger will create new "Pending" state
if foundStates, err = fetchStates(ctx, read, returnedSession.PublicId); err != nil {
return errors.Wrap(ctx, err, op)
}
if len(foundStates) == 0 {
return errors.New(ctx, errors.SessionNotFound, op, fmt.Sprintf("no states found for new session %s", returnedSession.PublicId))
}
if len(foundStates) != 1 {
return errors.New(ctx, errors.MultipleRecords, op, fmt.Sprintf("%d states found for new session %s", len(foundStates), returnedSession.PublicId))
}
returnedSession.States = foundStates
if returnedSession.States[0].Status != StatusPending {
return errors.New(ctx, errors.InvalidSessionState, op, fmt.Sprintf("new session %s state is not valid: %s", returnedSession.PublicId, returnedSession.States[0].Status))
}
return nil
},
)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return returnedSession, nil
}
// LookupSession will look up a session in the repository and return the session
// with its states. Returned States are ordered by start time descending. If the
// session is not found, it will return nil, nil, nil. If the session has no user
// or project associated with it, decryption of fields will not be performed.
// Supported Options:
// - WithIgnoreDecryptionFailures
func (r *Repository) LookupSession(ctx context.Context, sessionId string, opt ...Option) (*Session, *AuthzSummary, error) {
const op = "session.(Repository).LookupSession"
if sessionId == "" {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing session id")
}
opts := getOpts(opt...)
session := AllocSession()
session.PublicId = sessionId
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(read db.Reader, w db.Writer) error {
if err := read.LookupById(ctx, &session); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed for %s", sessionId)))
}
states, err := fetchStates(ctx, read, sessionId, db.WithOrder("start_time desc"))
if err != nil {
return errors.Wrap(ctx, err, op)
}
session.States = states
var dynamicCreds []*DynamicCredential
if err := read.SearchWhere(ctx, &dynamicCreds, "session_id = ?", []any{sessionId}); err != nil {
return errors.Wrap(ctx, err, op)
}
if len(dynamicCreds) > 0 {
session.DynamicCredentials = dynamicCreds
}
var staticCreds []*StaticCredential
if err := read.SearchWhere(ctx, &staticCreds, "session_id = ?", []any{sessionId}); err != nil {
return errors.Wrap(ctx, err, op)
}
if len(staticCreds) > 0 {
session.StaticCredentials = staticCreds
}
sessionHostSetHost := AllocSessionHostSetHost()
if err := read.LookupWhere(ctx, sessionHostSetHost, "session_id = ?", []any{sessionId}); err != nil && !errors.IsNotFoundError(err) {
return errors.Wrap(ctx, err, op)
}
session.HostSetId = sessionHostSetHost.HostSetId
session.HostId = sessionHostSetHost.HostId
sessionWorkerProtocol := AllocSessionWorkerProtocol()
if err := read.LookupWhere(ctx, sessionWorkerProtocol, "session_id = ?", []any{sessionId}); err != nil && !errors.IsNotFoundError(err) {
return errors.Wrap(ctx, err, op)
}
session.ProtocolWorkerId = sessionWorkerProtocol.WorkerId
connections, err := fetchConnections(ctx, read, sessionId, db.WithOrder("create_time desc"))
if err != nil {
return errors.Wrap(ctx, err, op)
}
session.Connections = connections
return nil
},
)
if err != nil {
if errors.IsNotFoundError(err) {
return nil, nil, nil
}
return nil, nil, errors.Wrap(ctx, err, op)
}
// Skip decryption if Project ID or UserId is missing,
// since it will just lead to errors, and the session
// is already canceled if either of those are empty.
if session.ProjectId != "" && session.UserId != "" {
if err := decrypt(ctx, r.kms, &session); err != nil && !opts.withIgnoreDecryptionFailures {
return nil, nil, errors.Wrap(ctx, err, op)
}
}
authzSummary, err := r.sessionAuthzSummary(ctx, sessionId)
if err != nil {
return nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to get authz summary"))
}
return &session, authzSummary, nil
}
// listSessions lists sessions. Sessions returned will be limited by the list
// permissions of the repository.
// Supported options:
// - withTerminated
// - withLimit
// - withStartPageAfterItem
func (r *Repository) listSessions(ctx context.Context, opt ...Option) ([]*Session, time.Time, error) {
const op = "session.(Repository).ListSessions"
where, args := r.listPermissionWhereClauses()
if len(where) == 0 {
return nil, time.Time{}, nil
}
opts := getOpts(opt...)
permissionWhereClause := "(" + strings.Join(where, " or ") + ")"
if !opts.withTerminated {
permissionWhereClause += " and termination_reason is null"
}
limit := r.defaultLimit
if opts.withLimit > 0 {
limit = opts.withLimit
}
query := fmt.Sprintf(listSessionsTemplate, permissionWhereClause, limit)
if opts.withStartPageAfterItem != nil {
query = fmt.Sprintf(listSessionsPageTemplate, permissionWhereClause, limit)
args = append(args,
sql.Named("last_item_create_time", opts.withStartPageAfterItem.GetCreateTime()),
sql.Named("last_item_id", opts.withStartPageAfterItem.GetPublicId()),
)
}
return r.querySessions(ctx, query, args)
}
// listSessionsRefresh lists sessions limited by the list
// permissions of the repository.
// Supported options:
// - withTerminated
// - withLimit
// - withStartPageAfterItem
func (r *Repository) listSessionsRefresh(ctx context.Context, updatedAfter time.Time, opt ...Option) ([]*Session, time.Time, error) {
const op = "session.(Repository).ListSessionsRefresh"
if updatedAfter.IsZero() {
return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing updated after time")
}
where, args := r.listPermissionWhereClauses()
if len(where) == 0 {
return nil, time.Time{}, nil
}
opts := getOpts(opt...)
permissionWhereClause := "(" + strings.Join(where, " or ") + ")"
if !opts.withTerminated {
permissionWhereClause += " and termination_reason is null"
}
limit := r.defaultLimit
if opts.withLimit > 0 {
limit = opts.withLimit
}
query := fmt.Sprintf(refreshSessionsTemplate, permissionWhereClause, limit)
args = append(args,
sql.Named("updated_after_time", timestamp.New(updatedAfter)),
)
if opts.withStartPageAfterItem != nil {
query = fmt.Sprintf(refreshSessionsPageTemplate, permissionWhereClause, limit)
args = append(args,
sql.Named("last_item_update_time", opts.withStartPageAfterItem.GetUpdateTime()),
sql.Named("last_item_id", opts.withStartPageAfterItem.GetPublicId()),
)
}
return r.querySessions(ctx, query, args)
}
func (r *Repository) querySessions(ctx context.Context, query string, args []any) ([]*Session, time.Time, error) {
const op = "session.(Repository).querySessions"
var sessions []*Session
var transactionTimestamp time.Time
if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(rd db.Reader, w db.Writer) error {
rows, err := rd.Query(ctx, query, args)
if err != nil {
return err
}
defer rows.Close()
var sessionsList []*sessionListView
for rows.Next() {
var s sessionListView
if err := rd.ScanRows(ctx, rows, &s); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("scan row failed"))
}
sessionsList = append(sessionsList, &s)
}
if err := rows.Err(); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("failed to get next row for session"))
}
sessions, err = r.convertToSessions(ctx, sessionsList)
if err != nil {
return errors.Wrap(ctx, err, op)
}
transactionTimestamp, err = rd.Now(ctx)
return err
}); err != nil {
return nil, time.Time{}, err
}
return sessions, transactionTimestamp, nil
}
// listDeletedIds lists the public IDs of any sessions deleted since the timestamp provided.
func (r *Repository) listDeletedIds(ctx context.Context, since time.Time) ([]string, time.Time, error) {
const op = "session.(Repository).listDeletedIds"
var deletedSessions []*deletedSession
var transactionTimestamp time.Time
if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, _ db.Writer) error {
if err := r.SearchWhere(ctx, &deletedSessions, "delete_time >= ?", []any{since}); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query deleted sessions"))
}
var err error
transactionTimestamp, err = r.Now(ctx)
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("failed to get transaction timestamp"))
}
return nil
}); err != nil {
return nil, time.Time{}, err
}
var sessionIds []string
for _, sess := range deletedSessions {
sessionIds = append(sessionIds, sess.PublicId)
}
return sessionIds, transactionTimestamp, nil
}
// estimatedCount returns an estimate of the total number of items in the session table.
func (r *Repository) estimatedCount(ctx context.Context) (int, error) {
const op = "session.(Repository).estimatedCount"
rows, err := r.reader.Query(ctx, estimateCountSessions, nil)
if err != nil {
return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total sessions"))
}
var count int
for rows.Next() {
if err := r.reader.ScanRows(ctx, rows, &count); err != nil {
return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total sessions"))
}
}
if err := rows.Err(); err != nil {
return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total sessions"))
}
return count, nil
}
// DeleteSession will delete a session from the repository.
func (r *Repository) DeleteSession(ctx context.Context, publicId string, _ ...Option) (int, error) {
const op = "session.(Repository).DeleteSession"
if publicId == "" {
return db.NoRowsAffected, errors.New(ctx, errors.InvalidParameter, op, "missing public id")
}
session := AllocSession()
session.PublicId = publicId
if err := r.reader.LookupByPublicId(ctx, &session); err != nil {
return db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed for %s", publicId)))
}
var rowsDeleted int
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(_ db.Reader, w db.Writer) error {
deleteSession := session.Clone()
var err error
rowsDeleted, err = w.Delete(
ctx,
deleteSession,
)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if rowsDeleted > 1 {
// return err, which will result in a rollback of the delete
return errors.New(ctx, errors.MultipleRecords, op, "more than 1 resource would have been deleted")
}
return nil
},
)
if err != nil {
return db.NoRowsAffected, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed for %s", publicId)))
}
return rowsDeleted, nil
}
// CancelSession sets a session's state to "canceling" in the repo. It's called
// when the user cancels a session and the controller wants to update the
// session state to "canceling" for the given reason, so the workers can get the
// "canceling signal" during their next status heartbeat. CancelSession is
// idempotent.
// Supported Options:
// - WithIgnoreDecryptionFailures
func (r *Repository) CancelSession(ctx context.Context, sessionId string, sessionVersion uint32, opt ...Option) (*Session, error) {
const op = "session.(Repository).CancelSession"
if sessionId == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing session id")
}
if sessionVersion == 0 {
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing session version")
}
s, ss, err := r.updateState(ctx, sessionId, sessionVersion, StatusCanceling, opt...)
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
s.States = ss
return s, nil
}
// TerminateCompletedSessions will terminate sessions in the repo based on:
// - sessions that have exhausted their connection limit and all their connections are closed.
// - sessions that are expired and all their connections are closed.
// - sessions that are canceling and all their connections are closed
//
// This function should called on a periodic basis a Controllers via it's
// "ticker" pattern.
func (r *Repository) TerminateCompletedSessions(ctx context.Context) (int, error) {
const op = "session.(Repository).TerminateCompletedSessions"
var rowsAffected int
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(reader db.Reader, w db.Writer) error {
var err error
rowsAffected, err = w.Exec(ctx, termSessionsUpdate, nil)
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
},
)
if err != nil {
return db.NoRowsAffected, errors.Wrap(ctx, err, op)
}
return rowsAffected, nil
}
// terminateSessionIfPossible is called on connection close and will attempt to close the connection's
// session if the following conditions are met:
// - sessions that have exhausted their connection limit and all their connections are closed.
// - sessions that are expired and all their connections are closed.
// - sessions that are canceling and all their connections are closed
func (r *Repository) terminateSessionIfPossible(ctx context.Context, sessionId string) (int, error) {
const op = "session.(Repository).terminateSessionIfPossible"
rowsAffected := 0
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(reader db.Reader, w db.Writer) error {
var err error
rowsAffected, err = w.Exec(ctx, terminateSessionIfPossible,
[]any{sql.Named("public_id", sessionId)})
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
},
)
if err != nil {
return db.NoRowsAffected, errors.Wrap(ctx, err, op)
}
return rowsAffected, nil
}
type AuthzSummary struct {
ExpirationTime *timestamp.Timestamp
ConnectionLimit int32
CurrentConnectionCount uint32
EgressWorkerFilter string
IngressWorkerFilter string
}
func (r *Repository) sessionAuthzSummary(ctx context.Context, sessionId string) (*AuthzSummary, error) {
const op = "session.(Repository).sessionAuthzSummary"
rows, err := r.reader.Query(ctx, remainingConnectionsCte, []any{sql.Named("session_id", sessionId)})
if err != nil {
return nil, errors.Wrap(ctx, err, op)
}
defer rows.Close()
var info *AuthzSummary
for rows.Next() {
if info != nil {
return nil, errors.New(ctx, errors.MultipleRecords, op, "query returned more than one row")
}
info = &AuthzSummary{}
if err := r.reader.ScanRows(ctx, rows, info); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("scan row failed"))
}
}
if err := rows.Err(); err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to get next row for session"))
}
return info, nil
}
// Lookup an activated session. Must run in a transaction.
func (r *Repository) lookupActivatedSessionTx(ctx context.Context, reader db.Reader, writer db.Writer, sessionId string,
tofuToken []byte, activatedSession *Session,
) error {
const op = "session.(Repository).lookupActivatedSessionTx"
var txErr error
if txErr = reader.LookupById(ctx, activatedSession); txErr != nil {
return errors.Wrap(ctx, txErr, op, errors.WithMsg(fmt.Sprintf("failed for %s", sessionId)))
}
if txErr = decrypt(ctx, r.kms, activatedSession); txErr != nil {
return errors.Wrap(ctx, txErr, op)
}
if len(activatedSession.TofuToken) > 0 && subtle.ConstantTimeCompare(activatedSession.TofuToken, tofuToken) != 1 {
return errors.New(ctx, errors.TokenMismatch, op, "tofu token mismatch")
}
return nil
}
// Return states for an activated session. Must run in a transaction.
func (r *Repository) fetchActivatedSessionStatesTx(ctx context.Context, reader db.Reader, sessionId string) ([]*State, error) {
const op = "session.(Repository).fetchActivatedSessionStatesTx"
var txErr error
var returnedStates []*State
returnedStates, txErr = fetchStates(ctx, reader, sessionId, db.WithOrder("start_time desc"))
if txErr != nil {
return nil, errors.Wrap(ctx, txErr, op)
}
return returnedStates, nil
}
// getActivatedSession is called if there was a duplicate attempt to activate a session
// It validates the tofu token matches and returns the session
func (r *Repository) getActivatedSession(ctx context.Context, sessionId string, tofuToken []byte) (*Session, []*State, error) {
const op = "session.(Repository).getActivatedSession"
activatedSession := AllocSession()
activatedSession.PublicId = sessionId
var returnedStates []*State
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(reader db.Reader, _ db.Writer) error {
err := reader.LookupById(ctx, &activatedSession)
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed for %s", sessionId)))
}
returnedStates, err = r.fetchActivatedSessionStatesTx(ctx, reader, sessionId)
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
},
)
if err != nil {
return nil, nil, errors.Wrap(ctx, err, op)
}
if err := decrypt(ctx, r.kms, &activatedSession); err != nil {
return nil, nil, errors.Wrap(ctx, err, op)
}
if len(activatedSession.TofuToken) > 0 && subtle.ConstantTimeCompare(activatedSession.TofuToken, tofuToken) != 1 {
return nil, nil, errors.New(ctx, errors.TokenMismatch, op, "tofu token mismatch")
}
return &activatedSession, returnedStates, nil
}
// ActivateSession will activate the session and is called by a worker after
// authenticating the session. The session must be in a "pending" state to be
// activated. States are ordered by start time descending. Returns an
// InvalidSessionState error code if a connection cannot be made because the session
// was canceled or terminated.
// If ActivateSession receives duplicate requests for the same session, it will return the
// already active session if the tofu token is correct
func (r *Repository) ActivateSession(ctx context.Context, sessionId string, sessionVersion uint32, tofuToken []byte) (*Session, []*State, error) {
const op = "session.(Repository).ActivateSession"
if sessionId == "" {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing session id")
}
if sessionVersion == 0 {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing version")
}
if len(tofuToken) == 0 {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing tofu token")
}
// Lookup session first to get the project id so the correct kms wrapper can be used for encrypting the tofu.
foundSession := AllocSession()
foundSession.PublicId = sessionId
if err := r.reader.LookupById(ctx, &foundSession); err != nil {
return nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed for %s", sessionId)))
}
// Encrypt the tofu before we start a database transaction to avoid holding the transaction while encrypting.
updatedSession := AllocSession()
updatedSession.PublicId = sessionId
updatedSession.TofuToken = tofuToken
sessionWrapper, err := r.kms.GetWrapper(ctx, foundSession.ProjectId, kms.KeyPurposeSessions)
if err != nil {
return nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get session wrapper"))
}
if err := updatedSession.encrypt(ctx, sessionWrapper); err != nil {
return nil, nil, errors.Wrap(ctx, err, op)
}
var tofuSeen bool
var returnedStates []*State
_, err = r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(reader db.Reader, w db.Writer) error {
rowsAffected, err := w.Exec(ctx, activateStateCte, []any{
sql.Named("session_id", sessionId),
sql.Named("version", sessionVersion),
})
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("unable to activate session %s", sessionId)))
}
if rowsAffected == 0 {
return errors.New(ctx, errors.InvalidSessionState, op, "session is not in a pending state")
}
foundSession = AllocSession()
foundSession.PublicId = sessionId
err = reader.LookupById(ctx, &foundSession)
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("failed for %s", sessionId)))
}
// If we already have recorded a tofu, we don't need to update anything.
// Once we are out of the transaction, we can decrypt and check if the
// recorded tofu matches.
if len(foundSession.CtTofuToken) > 0 {
tofuSeen = true
returnedStates, err = r.fetchActivatedSessionStatesTx(ctx, reader, sessionId)
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
}
rowsUpdated, err := w.Update(ctx, &updatedSession, []string{"CtTofuToken", "KeyId"}, nil)
if err != nil {
return errors.Wrap(ctx, err, op)
}
if err == nil && rowsUpdated > 1 {
// return err, which will result in a rollback of the update
return errors.New(ctx, errors.MultipleRecords, op, "more than 1 resource would have been updated")
}
returnedStates, err = r.fetchActivatedSessionStatesTx(ctx, reader, sessionId)
if err != nil {
return errors.Wrap(ctx, err, op)
}
return nil
},
)
if err != nil {
// If this was a duplicate activation attempt, return existing session if the tofu token matches
if errors.IsUniqueError(err) {
event.WriteSysEvent(ctx, op, fmt.Sprintf("ignoring duplicate session activation attempt for session %v", sessionId))
return r.getActivatedSession(ctx, sessionId, tofuToken)
}
return nil, nil, errors.Wrap(ctx, err, op)
}
if tofuSeen {
if err := decrypt(ctx, r.kms, &foundSession); err != nil {
return nil, nil, errors.Wrap(ctx, err, op)
}
if subtle.ConstantTimeCompare(foundSession.TofuToken, tofuToken) != 1 {
return nil, nil, errors.New(ctx, errors.TokenMismatch, op, "tofu token mismatch")
}
return &foundSession, returnedStates, nil
}
return &updatedSession, returnedStates, nil
}
// updateState will update the session's state using the session id and its
// version. updateState is idempotent. States are ordered by start time
// descending. No options are currently supported.
// Supported Options:
// - WithIgnoreDecryptionFailures
func (r *Repository) updateState(ctx context.Context, sessionId string, sessionVersion uint32, s Status, opt ...Option) (*Session, []*State, error) {
const op = "session.(Repository).updateState"
if sessionId == "" {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing session id")
}
if sessionVersion == 0 {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing version")
}
if s == "" {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "missing session status")
}
if s == StatusActive {
return nil, nil, errors.New(ctx, errors.InvalidParameter, op, "you must call ActivateSession to update a session's state to active")
}
opts := getOpts(opt...)
var rowsAffected int
updatedSession := AllocSession()
var returnedStates []*State
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(reader db.Reader, w db.Writer) error {
var err error
// We need to update the session version as that's the aggregate
updatedSession.PublicId = sessionId
updatedSession.Version = uint32(sessionVersion) + 1
rowsUpdated, err := w.Update(ctx, &updatedSession, []string{"Version"}, nil, db.WithVersion(&sessionVersion))
if err != nil {
return errors.Wrap(ctx, err, op)
}
if rowsUpdated != 1 {
return errors.New(ctx, errors.MultipleRecords, op, fmt.Sprintf("updated session and %d rows updated", rowsUpdated))
}
rowsAffected, err = w.Exec(ctx, updateSessionState, []any{
sql.Named("session_id", sessionId),
sql.Named("status", s.String()),
})
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("unable to update session %s state to %s", sessionId, s.String())))
}
if rowsAffected != 0 && rowsAffected != 1 {
return errors.New(ctx, errors.MultipleRecords, op, fmt.Sprintf("updated session %s to state %s and %d rows inserted (should be 0 or 1)", sessionId, s.String(), rowsAffected))
}
returnedStates, err = fetchStates(ctx, reader, sessionId, db.WithOrder("start_time desc"))
if err != nil {
return errors.Wrap(ctx, err, op)
}
if len(returnedStates) < 1 && returnedStates[0].Status != s {
return errors.New(ctx, errors.InvalidSessionState, op, fmt.Sprintf("failed to update %s to a state of %s", sessionId, s.String()))
}
hostSetHost, err := fetchHostSetHost(ctx, reader, sessionId)
if err != nil && !errors.IsNotFoundError(err) {
return errors.Wrap(ctx, err, op)
}
updatedSession.HostId = hostSetHost.HostId
updatedSession.HostSetId = hostSetHost.HostSetId
return nil
},
)
if err != nil {
return nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("error creating new state"))
}
if err := decrypt(ctx, r.kms, &updatedSession); err != nil && !opts.withIgnoreDecryptionFailures {
return nil, nil, errors.Wrap(ctx, err, op)
}
return &updatedSession, returnedStates, nil
}
// CheckIfNotActive checks the given sessions to see if they are in a
// non-active state, i.e. "canceling" or "terminated" It returns a *StateReport
// object for each session that is not active, with its current status.
func (r *Repository) CheckIfNotActive(ctx context.Context, reportedSessions []string) ([]*StateReport, error) {
const op = "session.(Repository).listSessionIdAndState"
notActive := make([]*StateReport, 0, len(reportedSessions))
if len(reportedSessions) <= 0 {
return notActive, nil
}
unrecognizedSessions := make(map[string]struct{})
for _, sessId := range reportedSessions {
unrecognizedSessions[sessId] = struct{}{}
}
_, err := r.writer.DoTx(
ctx,
db.StdRetryCnt,
db.ExpBackoff{},
func(reader db.Reader, _ db.Writer) error {
var states []*State
err := reader.SearchWhere(ctx, &states, "end_time is null and session_id in (?)", []any{reportedSessions})
if err != nil {
return errors.Wrap(ctx, err, op)
}
for _, s := range states {
delete(unrecognizedSessions, s.SessionId)
switch s.Status {
case StatusPending, StatusActive:
continue
case StatusCanceling, StatusTerminated:
default:
return errors.New(ctx, errors.Internal, op, fmt.Sprintf("unknown session state %q", s.Status))
}
notActive = append(notActive, &StateReport{
SessionId: s.SessionId,
Status: s.Status,
})
}
return nil
},
)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("error checking if sessions are no longer active"))
}
for s := range unrecognizedSessions {
notActive = append(notActive, &StateReport{
SessionId: s,
Unrecognized: true,
})
}
return notActive, nil
}
func (r *Repository) deleteSessionsTerminatedBefore(ctx context.Context, threshold time.Duration) (int, error) {
const op = "session.(Repository).deleteTerminated"
args := []any{
sql.Named("threshold_seconds", threshold.Seconds()),
}
c, err := r.writer.Exec(ctx, deleteTerminated, args)
if err != nil {
return 0, errors.Wrap(ctx, err, op, errors.WithMsg("error deleting terminated sessions"))
}
return c, nil
}
func fetchStates(ctx context.Context, r db.Reader, sessionId string, opt ...db.Option) ([]*State, error) {
const op = "session.fetchStates"
var states []*State
if err := r.SearchWhere(ctx, &states, "session_id = ?", []any{sessionId}, opt...); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if len(states) == 0 {
return nil, nil
}
return states, nil
}
func fetchConnections(ctx context.Context, r db.Reader, sessionId string, opt ...db.Option) ([]*Connection, error) {
const op = "session.fetchConnections"
var connections []*Connection
if err := r.SearchWhere(ctx, &connections, "session_id = ?", []any{sessionId}, opt...); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
if len(connections) == 0 {
return nil, nil
}
return connections, nil
}
func fetchHostSetHost(ctx context.Context, r db.Reader, sessionId string, opt ...db.Option) (*SessionHostSetHost, error) {
const op = "session.fetchHostSetHost"
var hostSetHost *SessionHostSetHost
if err := r.SearchWhere(ctx, &hostSetHost, "session_id = ?", []any{sessionId}, opt...); err != nil {
return nil, errors.Wrap(ctx, err, op)
}
return hostSetHost, nil
}
// decrypt decrypts encrypted fields of the Session.
func decrypt(ctx context.Context, kmsRepo kms.GetWrapperer, session *Session) error {
const op = "session.decrypt"
if util.IsNil(kmsRepo) {
return errors.New(ctx, errors.InvalidParameter, op, "missing kms repo")
}
if session == nil {
return errors.New(ctx, errors.InvalidParameter, op, "missing session")
}
if session.ProjectId == "" {
return errors.New(ctx, errors.InvalidParameter, op, "missing session project ID")
}
if session.KeyId == "" {
return errors.New(ctx, errors.InvalidParameter, op, "missing session key ID")
}
if session.UserId == "" {
return errors.New(ctx, errors.InvalidParameter, op, "missing session user ID")
}
if session.PublicId == "" {
return errors.New(ctx, errors.InvalidParameter, op, "missing session ID")
}
sessionWrapper, err := kmsRepo.GetWrapper(ctx, session.ProjectId, kms.KeyPurposeSessions, kms.WithKeyId(session.KeyId))
if err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("unable to get session wrapper"))
}
if err := session.decrypt(ctx, sessionWrapper); err != nil {
return errors.Wrap(ctx, err, op, errors.WithMsg("unable to decrypt session value"))
}
return nil
}