package database import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "edge-infra.dev/pkg/edge/iam/config" "edge-infra.dev/pkg/edge/iam/crypto" iamErrors "edge-infra.dev/pkg/edge/iam/errors" "edge-infra.dev/pkg/edge/iam/log" "edge-infra.dev/pkg/edge/iam/storage" "github.com/go-kivik/kivik/v4" "github.com/go-kivik/kivik/v4/couchdb" "github.com/go-logr/logr" "github.com/go-redis/redis" "github.com/ory/fosite" "github.com/pkg/errors" ) type ( KeyPrefix string Store struct { Log logr.Logger CouchDB *kivik.Client RedisDB *redis.Client Sessions *SessionStore CouchDBLocal *kivik.Client isOffline bool } Doc struct { ID string `json:"_id"` Value json.RawMessage `json:"value"` Rev string `json:"_rev,omitempty"` Expiration int64 `json:"expiration,omitempty"` } Options func(d *Doc) ) const ( KeyPrefixAuthorizationCode KeyPrefix = "auth-code" KeyPrefixAccessToken KeyPrefix = "access-token" KeyPrefixAccessTokenReq KeyPrefix = "access-token-request" KeyPrefixRefreshToken KeyPrefix = "refresh-token" KeyPrefixRefreshTokenReq KeyPrefix = "refresh-token-request" KeyPrefixOpenIDConnect KeyPrefix = "oidc" KeyPrefixPKCE KeyPrefix = "pkce" KeyPrefixClientCreds KeyPrefix = "client-creds" KeyPrefixClientProfile KeyPrefix = "client-profile" KeyPrefixClient KeyPrefix = "client" KeyPrefixPIN KeyPrefix = "pin" KeyPrefixDeviceAccount KeyPrefix = "device-acct" KeyPrefixProfile KeyPrefix = "profile" KeyPrefixAlias KeyPrefix = "alias" KeyPrefixDeviceLogin KeyPrefix = "device-login" KeyPrefixBarcode KeyPrefix = "barcode" KeyPrefixBarcodeCode KeyPrefix = "barcode-code" KeyPrefixBarcodeKey KeyPrefix = "barcode-key" KeyPrefixBarcodeUser KeyPrefix = "barcode-user" KeyPrefixLoginHint KeyPrefix = "login-hint" ) const AccountsDBName = "iam-accounts" // set the doc's expiration to the new time func WithExpiration(ttl time.Duration) Options { return func(d *Doc) { expiration := time.Now().Unix() + int64(ttl.Seconds()) d.Expiration = expiration } } // NewOperatorStore Store for operator func NewOperatorStore(log logr.Logger) (*Store, error) { couchdb, err := NewCouchDBClient(log) if err != nil { return nil, err } return &Store{ CouchDB: couchdb, Log: log, }, err } // NewProviderStore Store for provider func NewProviderStore(log logr.Logger) (*Store, error) { couchdb, err := NewCouchDBClient(log) if err != nil { return nil, err } redis, err := NewRedisClient() if err != nil { return nil, err } sessionStore, err := NewRedisSessionStore(context.Background(), redis) if err != nil { return nil, err } store := &Store{ RedisDB: redis, Sessions: sessionStore, CouchDB: couchdb, Log: log, } if config.IsTouchpoint() { var err error store.CouchDBLocal, err = LocalCouchDBClient(log) if err != nil { return nil, err } // periodically detect lan outage every 60s go store.periodicDetection(time.Second * 60) } return store, nil } type redisSchema struct { ID string `json:"id"` RequestedAt time.Time `json:"requestedAt"` ClientID string `json:"clientId"` Scopes fosite.Arguments `json:"scopes"` GrantedScopes fosite.Arguments `json:"grantedScopes"` Form url.Values `json:"formData"` Session json.RawMessage `json:"sessionData"` Active bool `json:"active"` } // type barcodeSchema struct { // redisSchema // // Credential is a unique generated credential for a barcode generation request. // Credential string `json:"credential"` // // CreatedAt refers to the timestamp when the barcode is generated as per user request. // CreatedAt time.Time `json:"createdAt"` // // ExpiresIn refers to the duration in seconds for which the barcode would be valid after creation. // ExpiresIn int64 `json:"expiresIn"` // Subject string `json:"subject"` // } func NewCouchDBClient(log logr.Logger) (*kivik.Client, error) { couchURI := config.CouchDBAddress() couchClient, err := kivik.New("couch", couchURI, couchdb.BasicAuth(config.CouchDBUser(), config.CouchDBPassword())) if err != nil { return nil, err } // do not check db and create repl_doc if it's touchpoint if config.IsTouchpoint() { return couchClient, err } exists, err := couchClient.DBExists(context.Background(), AccountsDBName) if err != nil { return nil, err } if !exists { if err := couchClient.CreateDB(context.Background(), AccountsDBName); err != nil { return nil, err } } // check for replication doc existence, and create if needed row := couchClient.DB(AccountsDBName).Get(context.Background(), "repl_doc") if row.Err() != nil { // nolint if kivik.HTTPStatus(row.Err()) == http.StatusNotFound { // nolint err := putReplDoc(couchClient.DB(AccountsDBName)) if err != nil { log.Error(err, "failed to PUT repl_doc into database") return nil, err } log.Info("successfully PUT repl_doc into database") } else { log.Error(row.Err(), "failed to retrieve repl_doc status") return nil, err } } else { log.Info("repl_doc exists, no action required") } log.Info("connected to store couchdb", "address", couchURI) return couchClient, nil } func LocalCouchDBClient(log logr.Logger) (*kivik.Client, error) { couchURI := config.CouchDBAddressLocal() // get couchdb lane username username, err := config.CouchDBUserLocal() if err != nil { return nil, err } // get couchdb lane password password, err := config.CouchDBPasswordLocal() if err != nil { return nil, err } couchClient, err := kivik.New("couch", couchURI, couchdb.BasicAuth(username, password)) if err != nil { return nil, err } log.Info("connected to touchpoint couchdb", "address", couchURI) return couchClient, nil } // add in the repl_doc to iam-accounts database func putReplDoc(db *kivik.DB) error { datasets := []byte(`{ "datasets": [{ "config": { "cancel": false, "continuous": true, "create_target": true, "doc_ids": null, "filter": "", "interval": "60000", "query_params": "", "selector": "", "since_seq": "", "source_proxy": "", "target_proxy": "", "use_checkpoints": false }, "name": "iam-accounts" }] }`) _, err := db.Put(context.Background(), "repl_doc", datasets) if err != nil { return err } return nil } func NewRedisClient() (*redis.Client, error) { logger := log.Logger() redisAddress := config.RedisAddress() db := redis.NewClient(&redis.Options{ Addr: redisAddress, }) err := db.Ping().Err() if err != nil { return nil, err } logger.Info("connected to redis", "address", redisAddress) return db, nil } func (s *Store) set(key string, request storage.Request, ttl time.Duration) error { payload, err := json.Marshal(request) if err != nil { return errors.WithStack(err) } if config.EncryptionEnabled() { encryptedValue, err := crypto.EncryptRedis(payload, config.EncryptionKey()) if err != nil { return errors.WithStack(err) } if err := s.RedisDB.Set(key, encryptedValue, ttl).Err(); err != nil { return errors.WithStack(err) } } else { if err := s.RedisDB.Set(key, payload, ttl).Err(); err != nil { return errors.WithStack(err) } } return nil } func (s *Store) get(key string) (*storage.Request, error) { resp, err := s.RedisDB.Get(key).Bytes() if err != nil { return nil, err } var schema redisSchema if config.EncryptionEnabled() { // nolint:nestif // if redis data is not encrypted, encrypt it, update it in redis, and continue if !isRedisDataEncrypted(resp) { ttl := s.RedisDB.TTL(key) encryptedVal, err := crypto.EncryptRedis(resp, config.EncryptionKey()) if err != nil { return nil, err } // update with encrypted value err = s.RedisDB.Set(key, encryptedVal, ttl.Val()).Err() if err != nil { return nil, err } } else { decryptedValue, err := crypto.DecryptRedis(string(resp), config.EncryptionKey()) if err != nil { return nil, err } resp = decryptedValue } } if err := json.Unmarshal(resp, &schema); err != nil { return nil, err } return &storage.Request{ ID: schema.ID, RequestedAt: schema.RequestedAt, ClientID: schema.ClientID, RequestedScope: schema.Scopes, GrantedScope: schema.GrantedScopes, Form: schema.Form, Session: schema.Session, Active: schema.Active, }, nil } // findDoc finds doc with given query func (s *Store) findDoc(ctx context.Context, query interface{}) ([]*Doc, error) { rows := s.CouchDB.DB(AccountsDBName).Find(ctx, query) var docs []*Doc for rows.Next() { var doc *Doc if err := rows.ScanDoc(&doc); err != nil { return nil, err } docs = append(docs, doc) } if rows.Err() != nil { return nil, rows.Err() } return docs, nil } func (s *Store) getRow(ctx context.Context, docID string) *kivik.Document { if s.CouchDBLocal != nil && s.IsOffline() { return s.CouchDBLocal.DB(AccountsDBName).Get(ctx, docID) } return s.CouchDB.DB(AccountsDBName).Get(ctx, docID) } // getDoc retrieves doc with given docID func (s *Store) getDoc(ctx context.Context, docID string) (*Doc, error) { // proactively detect lan outage when online // because periodicDetection only runs every 60s // this allows it failover faster if s.CouchDBLocal != nil && !s.IsOffline() { s.offlineDetection() } var row *kivik.Document var doc *Doc row = s.getRow(ctx, docID) if row.Err() != nil { if kivik.HTTPStatus(row.Err()) == http.StatusNotFound { return nil, nil } s.Log.Error(row.Err(), "failed to retrieve doc: ") return nil, row.Err() } if err := row.ScanDoc(&doc); err != nil { return nil, err } // if doc's expiration in unix is smaller the current time => expired => delete, and value is set (not 0) if doc.Expiration < time.Now().Unix() && doc.Expiration != 0 { if _, err := s.CouchDB.DB(AccountsDBName).Delete(ctx, docID, doc.Rev); err != nil { return nil, err } return nil, nil } if config.EncryptionEnabled() && startsWithPrefix(docID) { //nolint key := config.EncryptionKey() msg := &map[string]string{} // MOST of the time this returns a json.UnmarshalTypeError when the data is unencrypted. // However, in cases like client-creds, it can unmarshal it without issue (no error) // so we also check if (*msg)["EncryptedData"] = "" afterwords to double check and catch all cases err := json.Unmarshal(doc.Value, msg) if err != nil { // if not a json.UnmarshalTypeError, it's an actual error, return if _, ok := err.(*json.UnmarshalTypeError); !ok { return nil, err } } encryptedData := (*msg)["EncryptedData"] if encryptedData == "" { encryptedValue, err := crypto.EncryptJSON(doc.Value, key) if err != nil { return nil, err } doc.Value = encryptedValue // update the doc with the encrypted doc.Value if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, docID, doc); err != nil { return nil, err } } value, err := crypto.DecryptJSON(doc.Value, key) if err != nil { return nil, err } doc.Value = value } return doc, nil } // updateDoc update if already exists, create if not. Optional parameter to add in expiration time func (s *Store) updateDoc(ctx context.Context, docID string, value []byte, opts ...Options) error { var doc *Doc var err error if doc, err = s.getDoc(ctx, docID); err != nil { return err } if doc == nil { doc = &Doc{} } doc.ID = docID if config.EncryptionEnabled() && startsWithPrefix(docID) { encryptedValue, err := crypto.EncryptJSON(value, config.EncryptionKey()) if err != nil { return err } doc.Value = encryptedValue } else { doc.Value = value } // update expiration if added for _, o := range opts { o(doc) } // if we're a touchpoint (have local db) and we're offline, check again // cause it takes up to 60 secs to get back online if s.CouchDBLocal != nil && s.IsOffline() { s.offlineDetection() if s.IsOffline() { // we can't update the doc return iamErrors.ErrOffline } } // we can update the doc if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, docID, doc); err != nil { return err } return nil } // copydoc is used when migrating from BSL format (acct@user) to okta (username). // Copies over contents of old doc to new doc. Mainly used to carry over expiration. func (s *Store) copyDoc(ctx context.Context, docID string, value []byte, expiration int64) error { var doc *Doc var err error if doc, err = s.getDoc(ctx, docID); err != nil { return err } if doc == nil { doc = &Doc{} } doc.ID = docID doc.Value = value doc.Expiration = expiration if !s.IsOffline() { if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, docID, doc); err != nil { return err } } else { return iamErrors.ErrOffline } return nil } // createDoc only creates if not already exists func (s *Store) createDoc(ctx context.Context, docID string, value []byte, opts ...Options) error { var doc *Doc if config.EncryptionEnabled() { encryptedValue, err := crypto.EncryptJSON(value, config.EncryptionKey()) if err != nil { return err } doc = &Doc{ ID: docID, Value: encryptedValue, } } else { doc = &Doc{ ID: docID, Value: value, } } // update expiration if added for _, o := range opts { o(doc) } if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, docID, doc); err != nil && kivik.HTTPStatus(err) != http.StatusConflict { return err } return nil } func (s *Store) deleteDoc(ctx context.Context, docID string) error { var doc *Doc var err error if doc, err = s.getDoc(ctx, docID); err != nil { return err } if doc == nil { // not found, already deleted how? return nil } if _, err := s.CouchDB.DB(AccountsDBName).Delete(ctx, docID, doc.Rev); err != nil { return err } return nil } func (s *Store) periodicDetection(interval time.Duration) { for { s.offlineDetection() time.Sleep(interval) } } func (s *Store) offlineDetection() { pingCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() // get status of store db online, err := s.CouchDB.Ping(pingCtx) if err != nil && !s.IsOffline() { s.Log.Error(err, "network outage detected, failing over to touchpoint") } if online && s.IsOffline() { s.Log.Info("network outage recovered, switching back to store") } s.isOffline = !online } func (s *Store) IsOffline() bool { return s.isOffline } func (s *Store) RunOfflineDetection() { s.offlineDetection() } // func (s *Store) setBarcodeCode(key string, request storage.BarcodeCodeRequest) error { // payload, err := json.Marshal(request) // if err != nil { // return errors.WithStack(err) // } // if err := s.DB.SetNX(key, string(payload), 0).Err(); err != nil { // return errors.WithStack(err) // } // return nil // } // func (s *Store) setBarcode(key string, request storage.BarcodeRequest) error { // payload, err := json.Marshal(request) // if err != nil { // return errors.WithStack(err) // } // if err := s.DB.SetNX(key, string(payload), 0).Err(); err != nil { // return errors.WithStack(err) // } // return nil // } // func (s *Store) getBarcode(key string) (*storage.BarcodeRequest, error) { // resp, err := s.DB.Get(key).Bytes() // if err != nil { // return nil, err // } // var schema barcodeSchema // if err := json.Unmarshal(resp, &schema); err != nil { // return nil, err // } // return &storage.BarcodeRequest{ // Subject: schema.Subject, // Credential: schema.Credential, // CreatedAt: schema.CreatedAt, // ExpiresIn: schema.ExpiresIn, // }, nil // } // func (s *Store) getBarcodeCode(key string) (*storage.BarcodeCodeRequest, error) { // resp, err := s.DB.Get(key).Bytes() // if err != nil { // return nil, err // } // var schema barcodeSchema // if err := json.Unmarshal(resp, &schema); err != nil { // return nil, err // } // return &storage.BarcodeCodeRequest{ // Subject: schema.Subject, // CreatedAt: schema.CreatedAt, // ClientID: schema.ClientID, // }, nil // } func keyFrom(kp KeyPrefix, v string) string { key := fmt.Sprintf("%v:%v", kp, v) return key } // updates the couchdb database to use a new key to store encrypted items func (s *Store) RotateCouchEncryptionKey(ctx context.Context, oldKey []byte, newKey []byte) error { rows := s.CouchDB.DB(AccountsDBName).AllDocs(ctx) for rows.Next() { id, err := rows.ID() if err != nil { return err } // if it does not start with an edge-iam prefix, skip if !startsWithPrefix(id) { continue } // getting the doc, so it'll return unencrypted doc, err := s.GetDocWithKey(ctx, id, oldKey) if err != nil { return err } // encrypt it with the new key value, err := crypto.EncryptJSON(doc.Value, newKey) if err != nil { return err } doc.Value = value // update the doc if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, id, doc); err != nil { return err } } if rows.Err() != nil { return rows.Err() } return nil } // getDocWithKey retrieves doc with given docID given a specific key // this function assumes we have encryption enabled and that all current values are in the correct format // of encryptedValue: [value] func (s *Store) GetDocWithKey(ctx context.Context, docID string, oldKey []byte) (*Doc, error) { var row *kivik.Document var doc *Doc row = s.getRow(ctx, docID) if row.Err() != nil { if kivik.HTTPStatus(row.Err()) == http.StatusNotFound { return nil, nil } s.Log.Error(row.Err(), "failed to retrieve doc: ") return nil, row.Err() } if err := row.ScanDoc(&doc); err != nil { return nil, err } // if doc's expiration in unix is smaller the current time => expired => delete, and value is set (not 0) if doc.Expiration < time.Now().Unix() && doc.Expiration != 0 { if _, err := s.CouchDB.DB(AccountsDBName).Delete(ctx, docID, doc.Rev); err != nil { return nil, err } return nil, nil } value, err := crypto.DecryptJSON(doc.Value, oldKey) if err != nil { return nil, err } doc.Value = value return doc, nil } func (s *Store) EncryptCouchDB(ctx context.Context, key []byte) error { var doc *Doc rows := s.CouchDB.DB(AccountsDBName).AllDocs(ctx) for rows.Next() { id, err := rows.ID() if err != nil { return err } // if it does not start with the specific edge-iam prefixes, skip if !startsWithPrefix(id) { continue } row := s.getRow(ctx, id) if row.Err() != nil { if kivik.HTTPStatus(row.Err()) == http.StatusNotFound { return nil } s.Log.Error(row.Err(), "failed to retrieve doc: ") return row.Err() } if err := row.ScanDoc(&doc); err != nil { return err } // check if data is already encrypted encrypted, err := isEncrypted(doc.Value) if err != nil { return err } if !encrypted { encryptedValue, err := crypto.EncryptJSON(doc.Value, key) if err != nil { return err } doc.Value = encryptedValue // update the doc with the encrypted doc.Value if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, id, doc); err != nil { return err } } } if rows.Err() != nil { return rows.Err() } return nil } func (s *Store) DecryptCouchDB(ctx context.Context, key []byte) error { var doc *Doc rows := s.CouchDB.DB(AccountsDBName).AllDocs(ctx) for rows.Next() { id, err := rows.ID() if err != nil { return err } // if it does not start with the specific edge-iam prefixes, skip if !startsWithPrefix(id) { continue } row := s.getRow(ctx, id) if row.Err() != nil { if kivik.HTTPStatus(row.Err()) == http.StatusNotFound { return nil } s.Log.Error(row.Err(), "failed to retrieve doc: ") return row.Err() } if err := row.ScanDoc(&doc); err != nil { return err } // check if data is encrypted encrypted, err := isEncrypted(doc.Value) if err != nil { return err } if encrypted { encryptedValue, err := crypto.DecryptJSON(doc.Value, key) if err != nil { return err } doc.Value = encryptedValue // update the doc with the encrypted doc.Value if _, err := s.CouchDB.DB(AccountsDBName).Put(ctx, id, doc); err != nil { return err } } } if rows.Err() != nil { return rows.Err() } return nil } // Checks if the doc.Value has already been encrypted // If we get a json.UnmarshalTypeError, this means it cannot be unmarshalled into a // &map[string]string{}, which is the format of the EncryptedData. This implies the data we are unmarshalling is unencrypted. // However some data can fit into this format, so we double check by grabbing the value of 'EncryptedData'. // If empty -> unencrypted. If it has a value -> encrypted func isEncrypted(value json.RawMessage) (bool, error) { msg := &map[string]string{} err := json.Unmarshal(value, msg) if err != nil { // if error is json.UnmarshalTypeError, this means the data is not encrypted. if _, ok := err.(*json.UnmarshalTypeError); ok { return false, nil } return true, err } // grab the value from EncryptedData: [value] encryptedData := (*msg)["EncryptedData"] // if empty, we are not encrypted as it couldn't find anything if encryptedData != "" { return true, nil } return false, nil } // checks if a given id contains a valid prefix for encryption // all edge-iam prefixes except ones for client-profile and client-creds func startsWithPrefix(id string) bool { allowedPrefixes := []KeyPrefix{KeyPrefixAuthorizationCode, KeyPrefixAccessToken, KeyPrefixAccessTokenReq, KeyPrefixRefreshToken, KeyPrefixRefreshTokenReq, KeyPrefixOpenIDConnect, KeyPrefixPKCE, KeyPrefixPIN, KeyPrefixProfile, KeyPrefixAlias, KeyPrefixBarcode, KeyPrefixBarcodeCode, KeyPrefixBarcodeKey, KeyPrefixBarcodeUser, KeyPrefixLoginHint} for _, prefix := range allowedPrefixes { if strings.HasPrefix(id, string(prefix)) { return true } } return false }