go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/loginsessions/internal/store.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package internal
    16  
    17  import (
    18  	"context"
    19  	"sync"
    20  	"time"
    21  
    22  	"google.golang.org/protobuf/proto"
    23  
    24  	"go.chromium.org/luci/common/clock"
    25  	"go.chromium.org/luci/common/errors"
    26  	"go.chromium.org/luci/common/logging"
    27  	"go.chromium.org/luci/gae/service/datastore"
    28  
    29  	"go.chromium.org/luci/server/loginsessions/internal/statepb"
    30  )
    31  
    32  // ErrNoSession is returned by SessionStore if the login session is missing.
    33  var ErrNoSession = errors.New("no login session")
    34  
    35  // SessionStore is a storage layer for login sessions.
    36  type SessionStore interface {
    37  	// Create transactionally stores a session if it didn't exist before.
    38  	//
    39  	// The caller should have session.Id populated already with a random ID.
    40  	//
    41  	// Returns an error if there's already such session or the transaction failed.
    42  	Create(ctx context.Context, session *statepb.LoginSession) error
    43  
    44  	// Get returns an existing session or ErrNoSession if it is missing.
    45  	//
    46  	// Always returns a new copy of the protobuf message that can be safely
    47  	// mutated by the caller.
    48  	Get(ctx context.Context, sessionID string) (*statepb.LoginSession, error)
    49  
    50  	// Update transactionally updates an existing session.
    51  	//
    52  	// The callback is called to mutate the session in-place. The resulting
    53  	// session is then stored back (if it really was mutated). The callback may
    54  	// be called multiple times if the transaction is retried.
    55  	//
    56  	// If there's no such session returns ErrNoSession. May return other errors
    57  	// if the transaction fails.
    58  	//
    59  	// On success returns the session that is stored in the store now.
    60  	Update(ctx context.Context, sessionID string, cb func(*statepb.LoginSession)) (*statepb.LoginSession, error)
    61  
    62  	// Cleanup deletes login sessions that expired sufficiently long ago.
    63  	Cleanup(ctx context.Context) error
    64  }
    65  
    66  ////////////////////////////////////////////////////////////////////////////////
    67  
    68  // MemorySessionStore implements SessionStore using an in-memory map.
    69  //
    70  // For tests and running locally during development.
    71  type MemorySessionStore struct {
    72  	m        sync.Mutex
    73  	sessions map[string]*statepb.LoginSession
    74  }
    75  
    76  func (s *MemorySessionStore) Create(ctx context.Context, session *statepb.LoginSession) error {
    77  	if session.Id == "" {
    78  		panic("session ID is empty")
    79  	}
    80  	s.m.Lock()
    81  	defer s.m.Unlock()
    82  	if s.sessions[session.Id] != nil {
    83  		return errors.Reason("already have a session with this ID").Err()
    84  	}
    85  	if s.sessions == nil {
    86  		s.sessions = make(map[string]*statepb.LoginSession, 1)
    87  	}
    88  	s.sessions[session.Id] = proto.Clone(session).(*statepb.LoginSession)
    89  	return nil
    90  }
    91  
    92  func (s *MemorySessionStore) Get(ctx context.Context, sessionID string) (*statepb.LoginSession, error) {
    93  	s.m.Lock()
    94  	defer s.m.Unlock()
    95  	if session := s.sessions[sessionID]; session != nil {
    96  		return proto.Clone(session).(*statepb.LoginSession), nil
    97  	}
    98  	return nil, ErrNoSession
    99  }
   100  
   101  func (s *MemorySessionStore) Update(ctx context.Context, sessionID string, cb func(*statepb.LoginSession)) (*statepb.LoginSession, error) {
   102  	s.m.Lock()
   103  	defer s.m.Unlock()
   104  	session := s.sessions[sessionID]
   105  	if session == nil {
   106  		return nil, ErrNoSession
   107  	}
   108  	clone := proto.Clone(session).(*statepb.LoginSession)
   109  	cb(clone)
   110  	if clone.Id != sessionID {
   111  		panic("changing session ID is forbidden")
   112  	}
   113  	s.sessions[sessionID] = proto.Clone(clone).(*statepb.LoginSession)
   114  	return clone, nil
   115  }
   116  
   117  func (s *MemorySessionStore) Cleanup(ctx context.Context) error {
   118  	// Don't care about the cleanup for in-memory implementation.
   119  	return nil
   120  }
   121  
   122  ////////////////////////////////////////////////////////////////////////////////
   123  
   124  // Keep session for 7 extra days in case we need to investigate login issues.
   125  const sessionCleanupDelay = 7 * 24 * time.Hour
   126  
   127  type loginSessionEntity struct {
   128  	_extra datastore.PropertyMap `gae:"-,extra"`
   129  	_kind  string                `gae:"$kind,loginsessions.LoginSession"`
   130  
   131  	ID      string `gae:"$id"`
   132  	Session *statepb.LoginSession
   133  	TTL     time.Time
   134  }
   135  
   136  // DatastoreSessionStore implements SessionStore using Cloud Datastore.
   137  type DatastoreSessionStore struct{}
   138  
   139  func (s *DatastoreSessionStore) Create(ctx context.Context, session *statepb.LoginSession) error {
   140  	if session.Id == "" {
   141  		panic("session ID is empty")
   142  	}
   143  	return datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   144  		switch err := datastore.Get(ctx, &loginSessionEntity{ID: session.Id}); {
   145  		case err == nil:
   146  			return errors.Reason("already have a session with this ID").Err()
   147  		case err != datastore.ErrNoSuchEntity:
   148  			return errors.Annotate(err, "failed to check if the session already exists").Err()
   149  		}
   150  		return datastore.Put(ctx, &loginSessionEntity{
   151  			ID:      session.Id,
   152  			Session: session,
   153  			TTL:     session.Expiry.AsTime().Add(sessionCleanupDelay).UTC(),
   154  		})
   155  	}, nil)
   156  }
   157  
   158  func (s *DatastoreSessionStore) Get(ctx context.Context, sessionID string) (*statepb.LoginSession, error) {
   159  	ent := loginSessionEntity{ID: sessionID}
   160  	switch err := datastore.Get(ctx, &ent); {
   161  	case err == nil:
   162  		return ent.Session, nil
   163  	case err == datastore.ErrNoSuchEntity:
   164  		return nil, ErrNoSession
   165  	default:
   166  		return nil, errors.Annotate(err, "datastore error fetching session").Err()
   167  	}
   168  }
   169  
   170  func (s *DatastoreSessionStore) Update(ctx context.Context, sessionID string, cb func(*statepb.LoginSession)) (*statepb.LoginSession, error) {
   171  	var stored *statepb.LoginSession
   172  
   173  	err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   174  		ent := loginSessionEntity{ID: sessionID}
   175  		switch err := datastore.Get(ctx, &ent); {
   176  		case err == datastore.ErrNoSuchEntity:
   177  			return ErrNoSession
   178  		case err != nil:
   179  			return errors.Annotate(err, "error fetching session").Err()
   180  		}
   181  		clone := proto.Clone(ent.Session).(*statepb.LoginSession)
   182  		cb(clone)
   183  		if !proto.Equal(ent.Session, clone) {
   184  			if ent.Session.Id != clone.Id {
   185  				panic("changing session ID is forbidden")
   186  			}
   187  			ent.Session = clone
   188  			ent.TTL = clone.Expiry.AsTime().Add(sessionCleanupDelay).UTC()
   189  			if err := datastore.Put(ctx, &ent); err != nil {
   190  				return errors.Annotate(err, "failed to store updated session").Err()
   191  			}
   192  		}
   193  		stored = clone
   194  		return nil
   195  	}, nil)
   196  
   197  	switch {
   198  	case err == ErrNoSession:
   199  		return nil, err
   200  	case err != nil:
   201  		return nil, errors.Annotate(err, "session transaction error").Err()
   202  	default:
   203  		return stored, nil
   204  	}
   205  }
   206  
   207  func (s *DatastoreSessionStore) Cleanup(ctx context.Context) error {
   208  	q := datastore.NewQuery("loginsessions.LoginSession").
   209  		Lt("TTL", clock.Now(ctx).UTC()).
   210  		KeysOnly(true)
   211  
   212  	var batch []*datastore.Key
   213  
   214  	// Best effort deletion. The cron will keep retrying anyway.
   215  	deleteBatch := func() {
   216  		if err := datastore.Delete(ctx, batch); err != nil {
   217  			logging.Warningf(ctx, "Failed to delete a batch of sessions: %s", err)
   218  		} else {
   219  			batch = batch[:0]
   220  		}
   221  	}
   222  
   223  	err := datastore.Run(ctx, q, func(key *datastore.Key) {
   224  		logging.Infof(ctx, "Cleaning up session %s", key.StringID())
   225  		batch = append(batch, key)
   226  		if len(batch) >= 50 {
   227  			deleteBatch()
   228  		}
   229  	})
   230  
   231  	deleteBatch()
   232  	return err
   233  }