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{}