github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/account/accounts.go (about)

     1  package account
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"net/url"
     7  	"time"
     8  
     9  	"github.com/cozy/cozy-stack/model/app"
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	"github.com/cozy/cozy-stack/model/job"
    12  	"github.com/cozy/cozy-stack/model/permission"
    13  	"github.com/cozy/cozy-stack/pkg/consts"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb"
    15  	"github.com/cozy/cozy-stack/pkg/logger"
    16  	"github.com/cozy/cozy-stack/pkg/metadata"
    17  	"github.com/cozy/cozy-stack/pkg/prefixer"
    18  	"github.com/hashicorp/go-multierror"
    19  )
    20  
    21  // Account holds configuration information for an account
    22  type Account struct {
    23  	DocID         string                 `json:"_id,omitempty"`
    24  	DocRev        string                 `json:"_rev,omitempty"`
    25  	Relationships map[string]interface{} `json:"relationships,omitempty"`
    26  	Metadata      *metadata.CozyMetadata `json:"cozyMetadata,omitempty"`
    27  
    28  	AccountType       string                   `json:"account_type"`
    29  	Name              string                   `json:"name"`                        // Filled during creation request
    30  	FolderPath        string                   `json:"folderPath,omitempty"`        // Legacy. Replaced by DefaultFolderPath
    31  	DefaultFolderPath string                   `json:"defaultFolderPath,omitempty"` // Computed from other attributes if not provided
    32  	Identifier        string                   `json:"identifier,omitempty"`        // Name of the Basic attribute used as identifier
    33  	Basic             *BasicInfo               `json:"auth,omitempty"`
    34  	Oauth             *OauthInfo               `json:"oauth,omitempty"`
    35  	Extras            map[string]interface{}   `json:"oauth_callback_results,omitempty"`
    36  	Data              map[string]interface{}   `json:"data,omitempty"`
    37  	State             string                   `json:"state,omitempty"`
    38  	TwoFACode         string                   `json:"twoFACode,omitempty"`
    39  	MutedErrors       []map[string]interface{} `json:"mutedErrors,omitempty"`
    40  	Token             string                   `json:"token,omitempty"`   // Used by bi-aggregator
    41  	UserID            string                   `json:"user_id,omitempty"` // Used by bi-aggregator-user
    42  
    43  	// When an account is deleted, the stack cleans the triggers and calls its
    44  	// konnector to clean the account remotely (when available). It is done via
    45  	// a hook on deletion, but when the konnector is removed, this cleaning is
    46  	// done manually before uninstalling the konnector, and this flag is used
    47  	// to not try doing the cleaning in the hook as it is already too late (the
    48  	// konnector is no longer available).
    49  	ManualCleaning bool `json:"manual_cleaning,omitempty"`
    50  }
    51  
    52  // OauthInfo holds configuration information for an oauth account
    53  type OauthInfo struct {
    54  	AccessToken  string      `json:"access_token,omitempty"`
    55  	TokenType    string      `json:"token_type,omitempty"`
    56  	ExpiresAt    time.Time   `json:"expires_at,omitempty"`
    57  	RefreshToken string      `json:"refresh_token,omitempty"`
    58  	ClientID     string      `json:"client_id,omitempty"`
    59  	ClientSecret string      `json:"client_secret,omitempty"`
    60  	Query        *url.Values `json:"query,omitempty"`
    61  }
    62  
    63  // BasicInfo holds configuration information for an user/pass account
    64  type BasicInfo struct {
    65  	Login                string `json:"login,omitempty"`
    66  	Email                string `json:"email,omitempty"`          // Legacy, used in some accounts instead of login
    67  	Identifier           string `json:"identifier,omitempty"`     // Legacy, used in some accounts instead of login
    68  	NewIdentifier        string `json:"new_identifier,omitempty"` // Legacy, used in some accounts instead of login
    69  	AccountName          string `json:"accountName,omitempty"`    // Used when konnector has no credentials
    70  	Password             string `json:"password,omitempty"`       // Legacy, used when no encryption
    71  	EncryptedCredentials string `json:"credentials_encrypted,omitempty"`
    72  	Token                string `json:"token,omitempty"` // Used by legacy OAuth konnectors
    73  }
    74  
    75  // ID is used to implement the couchdb.Doc interface
    76  func (ac *Account) ID() string { return ac.DocID }
    77  
    78  // Rev is used to implement the couchdb.Doc interface
    79  func (ac *Account) Rev() string { return ac.DocRev }
    80  
    81  // SetID is used to implement the couchdb.Doc interface
    82  func (ac *Account) SetID(id string) { ac.DocID = id }
    83  
    84  // SetRev is used to implement the couchdb.Doc interface
    85  func (ac *Account) SetRev(rev string) { ac.DocRev = rev }
    86  
    87  // DocType implements couchdb.Doc
    88  func (ac *Account) DocType() string { return consts.Accounts }
    89  
    90  // Clone implements couchdb.Doc
    91  func (ac *Account) Clone() couchdb.Doc {
    92  	cloned := *ac
    93  	if ac.Oauth != nil {
    94  		tmp := *ac.Oauth
    95  		cloned.Oauth = &tmp
    96  	}
    97  	if ac.Basic != nil {
    98  		tmp := *ac.Basic
    99  		cloned.Basic = &tmp
   100  	}
   101  	cloned.Extras = make(map[string]interface{})
   102  	for k, v := range ac.Extras {
   103  		cloned.Extras[k] = v
   104  	}
   105  	cloned.Relationships = make(map[string]interface{})
   106  	for k, v := range ac.Relationships {
   107  		cloned.Relationships[k] = v
   108  	}
   109  	return &cloned
   110  }
   111  
   112  // Fetch implements permission.Fetcher
   113  func (ac *Account) Fetch(field string) []string {
   114  	switch field {
   115  	case "account_type":
   116  		return []string{ac.AccountType}
   117  	default:
   118  		return nil
   119  	}
   120  }
   121  
   122  func (ac *Account) toJSONDoc() (*couchdb.JSONDoc, error) {
   123  	buf, err := json.Marshal(ac)
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  	doc := couchdb.JSONDoc{}
   128  	if err := json.Unmarshal(buf, &doc); err != nil {
   129  		return nil, err
   130  	}
   131  	return &doc, nil
   132  }
   133  
   134  // GetTriggers returns the list of triggers associated with the given
   135  // accountID. In particular, the stack will need to remove them when the
   136  // account is deleted.
   137  func GetTriggers(jobsSystem job.JobSystem, db prefixer.Prefixer, accountID string) ([]job.Trigger, error) {
   138  	triggers, err := jobsSystem.GetAllTriggers(db)
   139  	if err != nil {
   140  		return nil, err
   141  	}
   142  
   143  	var toDelete []job.Trigger
   144  	for _, t := range triggers {
   145  		if !t.Infos().IsKonnectorTrigger() {
   146  			continue
   147  		}
   148  
   149  		var msg struct {
   150  			Account string `json:"account"`
   151  		}
   152  		err := t.Infos().Message.Unmarshal(&msg)
   153  		if err == nil && msg.Account == accountID {
   154  			toDelete = append(toDelete, t)
   155  		}
   156  	}
   157  	return toDelete, nil
   158  }
   159  
   160  // CleanEntry is a struct with an account and its associated trigger.
   161  type CleanEntry struct {
   162  	Account          *Account
   163  	Triggers         []job.Trigger
   164  	ManifestOnDelete bool // the manifest of the konnector has a field "on_delete_account"
   165  	Slug             string
   166  }
   167  
   168  // CleanAndWait deletes the accounts. If an account is for a konnector with
   169  // "on_delete_account", a job is pushed and it waits for the job success to
   170  // continue. Finally, the associated trigger can be deleted.
   171  func CleanAndWait(inst *instance.Instance, toClean []CleanEntry) error {
   172  	ch := make(chan error)
   173  	for i := range toClean {
   174  		go func(entry CleanEntry) {
   175  			ch <- cleanAndWaitSingle(inst, entry)
   176  		}(toClean[i])
   177  	}
   178  	var errm error
   179  	for range toClean {
   180  		if err := <-ch; err != nil {
   181  			inst.Logger().
   182  				WithNamespace("accounts").
   183  				WithField("critical", "true").
   184  				Errorf("Error on delete_for_account: %v", err)
   185  			errm = multierror.Append(errm, err)
   186  		}
   187  	}
   188  	return errm
   189  }
   190  
   191  func cleanAndWaitSingle(inst *instance.Instance, entry CleanEntry) error {
   192  	jobsSystem := job.System()
   193  	acc := entry.Account
   194  	createSoftDeletedAccount(inst, acc)
   195  	acc.ManualCleaning = true
   196  	oldRev := acc.Rev() // The deletion job needs the rev just before the deletion
   197  	if err := couchdb.DeleteDoc(inst, acc); err != nil {
   198  		return err
   199  	}
   200  	// If the konnector has a field "on_delete_account", we need to execute a job
   201  	// for this konnector to clean the account on the remote API, and
   202  	// wait for this job to be done before uninstalling the konnector.
   203  	if entry.ManifestOnDelete {
   204  		j, err := PushAccountDeletedJob(jobsSystem, inst, acc.ID(), oldRev, entry.Slug)
   205  		if err != nil {
   206  			return err
   207  		}
   208  		err = j.WaitUntilDone(inst)
   209  		if err != nil {
   210  			return err
   211  		}
   212  	}
   213  	for _, t := range entry.Triggers {
   214  		err := jobsSystem.DeleteTrigger(inst, t.ID())
   215  		if err != nil {
   216  			inst.Logger().WithNamespace("accounts").
   217  				Errorf("Cannot delete the trigger: %v", err)
   218  		}
   219  	}
   220  	return nil
   221  }
   222  
   223  // PushAccountDeletedJob adds a job for the given account and konnector with
   224  // the AccountDeleted flag, to allow the konnector to clear the account
   225  // remotely.
   226  func PushAccountDeletedJob(jobsSystem job.JobSystem, db prefixer.Prefixer, accountID, accountRev, konnector string) (*job.Job, error) {
   227  	logger.WithDomain(db.DomainName()).
   228  		WithField("account_id", accountID).
   229  		WithField("account_rev", accountRev).
   230  		WithField("konnector", konnector).
   231  		Info("Pushing job for konnector on_delete")
   232  
   233  	msg, err := job.NewMessage(struct {
   234  		Account        string `json:"account"`
   235  		AccountRev     string `json:"account_rev"`
   236  		Konnector      string `json:"konnector"`
   237  		AccountDeleted bool   `json:"account_deleted"`
   238  	}{
   239  		Account:        accountID,
   240  		AccountRev:     accountRev,
   241  		Konnector:      konnector,
   242  		AccountDeleted: true,
   243  	})
   244  	if err != nil {
   245  		return nil, err
   246  	}
   247  	return jobsSystem.PushJob(db, &job.JobRequest{
   248  		WorkerType: "konnector",
   249  		Message:    msg,
   250  		Manual:     true, // Select high-priority for these jobs
   251  	})
   252  }
   253  
   254  // ComputeName tries to use the value of the `auth` attribute pointed by the
   255  // value of the `identifier` attribute as the Account name and set it in the
   256  // JSON document.
   257  //
   258  // See https://github.com/cozy/cozy-doctypes/blob/master/docs/io.cozy.accounts.md#about-the-name-of-the-account
   259  func ComputeName(doc couchdb.JSONDoc) {
   260  	auth, ok := doc.M["auth"].(map[string]interface{})
   261  	if !ok || auth == nil {
   262  		return
   263  	}
   264  
   265  	identifier, ok := doc.M["identifier"].(string)
   266  	if !ok || identifier == "" {
   267  		if login, ok := auth["login"].(string); ok {
   268  			doc.M["name"] = login
   269  		}
   270  		return
   271  	}
   272  
   273  	if name, ok := auth[identifier].(string); ok {
   274  		doc.M["name"] = name
   275  	}
   276  }
   277  
   278  func init() {
   279  	couchdb.AddHook(consts.Accounts, couchdb.EventDelete,
   280  		func(db prefixer.Prefixer, doc couchdb.Doc, old couchdb.Doc) error {
   281  			logger.WithDomain(db.DomainName()).
   282  				WithField("account_id", old.ID()).
   283  				Info("Executing account deletion hook")
   284  
   285  			manualCleaning := false
   286  			switch v := doc.(type) {
   287  			case *Account:
   288  				manualCleaning = v.ManualCleaning
   289  			case *couchdb.JSONDoc:
   290  				manualCleaning, _ = v.M["manual_cleaning"].(bool)
   291  			}
   292  			if manualCleaning {
   293  				return nil
   294  			}
   295  
   296  			jobsSystem := job.System()
   297  			triggers, err := GetTriggers(jobsSystem, db, doc.ID())
   298  			if err != nil {
   299  				logger.WithDomain(db.DomainName()).Errorf(
   300  					"Failed to fetch triggers after account deletion: %s", err)
   301  				return err
   302  			}
   303  			for _, t := range triggers {
   304  				if err := jobsSystem.DeleteTrigger(db, t.ID()); err != nil {
   305  					logger.WithDomain(db.DomainName()).
   306  						Errorf("failed to delete orphan trigger: %s", err)
   307  				}
   308  			}
   309  
   310  			// When an account is deleted, we need to push a new job in order to
   311  			// delete possible data associated with this account. This is done via
   312  			// this hook.
   313  			//
   314  			// This may require additionnal specifications to allow konnectors to
   315  			// define more explicitly when and how they want to be called in order to
   316  			// cleanup or update their associated content. For now we make this
   317  			// process really specific to the deletion of an account, which is our
   318  			// only detailed usecase.
   319  			if old == nil {
   320  				return nil
   321  			}
   322  
   323  			var konnector string
   324  			switch v := old.(type) {
   325  			case *Account:
   326  				konnector = v.AccountType
   327  			case *couchdb.JSONDoc:
   328  				konnector, _ = v.M["account_type"].(string)
   329  			}
   330  			if konnector == "" {
   331  				logger.WithDomain(db.DomainName()).
   332  					WithField("account_id", old.ID()).
   333  					WithField("account_rev", old.Rev()).
   334  					Info("No associated konnector for account: cannot create on_delete job")
   335  				return nil
   336  			}
   337  
   338  			createSoftDeletedAccount(db, old)
   339  
   340  			// Execute the OnDeleteAccount if the konnector has declared one
   341  			man, err := app.GetKonnectorBySlug(db, konnector)
   342  			if man != nil && man.OnDeleteAccount() != "" {
   343  				_, err = PushAccountDeletedJob(jobsSystem, db, old.ID(), old.Rev(), konnector)
   344  				return err
   345  			}
   346  			if !errors.Is(err, app.ErrNotFound) {
   347  				return err
   348  			}
   349  
   350  			return nil
   351  		})
   352  }
   353  
   354  func createSoftDeletedAccount(db prefixer.Prefixer, old couchdb.Doc) {
   355  	var cloned *couchdb.JSONDoc
   356  	switch old := old.(type) {
   357  	case *Account:
   358  		doc, err := old.toJSONDoc()
   359  		if err != nil {
   360  			logger.WithDomain(db.DomainName()).Errorf("Failed to soft-delete account: %s", err)
   361  			return
   362  		}
   363  		cloned = doc
   364  	case *couchdb.JSONDoc:
   365  		cloned = old.Clone().(*couchdb.JSONDoc)
   366  	default:
   367  		return
   368  	}
   369  
   370  	cloned.Type = consts.SoftDeletedAccounts
   371  	cloned.M["soft_deleted_rev"] = cloned.Rev()
   372  	cloned.SetRev("")
   373  	if err := createNamedDocWithDB(db, cloned); err != nil {
   374  		logger.WithDomain(db.DomainName()).Errorf("Failed to soft-delete account: %s", err)
   375  	}
   376  	if err := couchdb.Compact(db, consts.Accounts); err != nil {
   377  		logger.WithDomain(db.DomainName()).Infof("Failed to compact accounts: %s", err)
   378  	}
   379  }
   380  
   381  func createNamedDocWithDB(db prefixer.Prefixer, doc couchdb.Doc) error {
   382  	err := couchdb.CreateNamedDoc(db, doc)
   383  	if couchdb.IsNoDatabaseError(err) {
   384  		// XXX Ignore errors: we can have several requests in parallel to
   385  		// create the database, and only one of them will succeed, but the
   386  		// stack can still create documents in other goroutines / servers.
   387  		_ = couchdb.CreateDB(db, doc.DocType())
   388  		return couchdb.CreateNamedDoc(db, doc)
   389  	}
   390  	return err
   391  }
   392  
   393  var _ permission.Fetcher = &Account{}