github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/bi/webhook.go (about) 1 package bi 2 3 import ( 4 "crypto/subtle" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "os" 9 "strings" 10 11 "github.com/cozy/cozy-stack/model/account" 12 "github.com/cozy/cozy-stack/model/app" 13 "github.com/cozy/cozy-stack/model/instance" 14 "github.com/cozy/cozy-stack/model/job" 15 "github.com/cozy/cozy-stack/pkg/assets/statik" 16 "github.com/cozy/cozy-stack/pkg/consts" 17 "github.com/cozy/cozy-stack/pkg/couchdb" 18 "github.com/cozy/cozy-stack/pkg/metadata" 19 ) 20 21 // aggregatorID is the ID of the io.cozy.account CouchDB document where the 22 // user BI token is persisted. 23 const aggregatorID = "bi-aggregator" 24 25 const aggregatorUserID = "bi-aggregator-user" 26 27 // EventBI is a type used for the events sent by BI in the webhooks 28 type EventBI string 29 30 const ( 31 // EventConnectionSynced is emitted after a connection has been synced 32 EventConnectionSynced EventBI = "CONNECTION_SYNCED" 33 // EventConnectionDeleted is emitted after a connection has been deleted 34 EventConnectionDeleted EventBI = "CONNECTION_DELETED" 35 // EventAccountEnabled is emitted after a bank account was enabled 36 EventAccountEnabled EventBI = "ACCOUNT_ENABLED" 37 // EventAccountDisabled is emitted after a bank account was disabled 38 EventAccountDisabled EventBI = "ACCOUNT_DISABLED" 39 ) 40 41 // ParseEventBI returns the event of the webhook, or an error if the event 42 // cannot be handled by the stack. 43 func ParseEventBI(evt string) (EventBI, error) { 44 if evt == "" { 45 return EventConnectionSynced, nil 46 } 47 48 biEvent := EventBI(strings.ToUpper(evt)) 49 switch biEvent { 50 case EventConnectionSynced, EventConnectionDeleted, 51 EventAccountEnabled, EventAccountDisabled: 52 return biEvent, nil 53 } 54 return EventBI("INVALID"), errors.New("invalid event") 55 } 56 57 // WebhookCall contains the data relative to a call from BI for a webhook. 58 type WebhookCall struct { 59 Instance *instance.Instance 60 Token string 61 BIurl string 62 Event EventBI 63 Payload map[string]interface{} 64 65 accounts []*account.Account 66 } 67 68 // Fire is used when the stack receives a call for a BI webhook, with an bearer 69 // token and a JSON payload. It will try to find a matching io.cozy.account and 70 // a io.cozy.trigger, and launch a job for them if needed. 71 func (c *WebhookCall) Fire() error { 72 var accounts []*account.Account 73 if err := couchdb.GetAllDocs(c.Instance, consts.Accounts, nil, &accounts); err != nil { 74 return err 75 } 76 c.accounts = accounts 77 78 if err := c.checkToken(); err != nil { 79 return err 80 } 81 82 switch c.Event { 83 case EventConnectionSynced: 84 return c.handleConnectionSynced() 85 case EventConnectionDeleted: 86 return c.handleConnectionDeleted() 87 case EventAccountEnabled, EventAccountDisabled: 88 return c.handleAccountEnabledOrDisabled() 89 } 90 return errors.New("event not handled") 91 } 92 93 func (c *WebhookCall) checkToken() error { 94 for _, acc := range c.accounts { 95 if acc.ID() == aggregatorID { 96 if subtle.ConstantTimeCompare([]byte(c.Token), []byte(acc.Token)) == 1 { 97 return nil 98 } 99 return errors.New("token is invalid") 100 } 101 } 102 return errors.New("no bi-aggregator account found") 103 } 104 105 func (c *WebhookCall) handleConnectionSynced() error { 106 connID, err := extractPayloadConnID(c.Payload) 107 if err != nil { 108 return err 109 } 110 if connID == 0 { 111 return errors.New("no connection.id") 112 } 113 114 uuid, err := extractPayloadConnectionConnectorUUID(c.Payload) 115 if err != nil { 116 return err 117 } 118 slug, err := mapUUIDToSlug(uuid) 119 if err != nil { 120 c.Instance.Logger().WithNamespace("webhook"). 121 Warnf("no slug found for uuid %s: %s", uuid, err) 122 return err 123 } 124 konn, err := app.GetKonnectorBySlug(c.Instance, slug) 125 if err != nil { 126 userID, _ := extractPayloadUserID(c.Payload) 127 c.Instance.Logger().WithNamespace("webhook"). 128 Warnf("konnector not installed id_connection=%d id_user=%d uuid=%s slug=%s", connID, userID, uuid, slug) 129 return nil 130 } 131 132 var trigger job.Trigger 133 account, err := findAccount(c.accounts, connID) 134 if err != nil { 135 account, trigger, err = c.createAccountAndTrigger(konn, connID) 136 } else { 137 trigger, err = findTrigger(c.Instance, account) 138 if err != nil { 139 trigger, err = konn.CreateTrigger(c.Instance, account.ID(), "") 140 } 141 } 142 if err != nil { 143 return err 144 } 145 146 if c.mustExecuteKonnector(trigger) { 147 return c.fireTrigger(trigger, account) 148 } 149 return c.copyLastUpdate(account, konn) 150 } 151 152 func mapUUIDToSlug(uuid string) (string, error) { 153 f := statik.GetAsset("/mappings/bi-banks.json") 154 if f == nil { 155 return "", os.ErrNotExist 156 } 157 var mapping map[string]string 158 if err := json.Unmarshal(f.GetData(), &mapping); err != nil { 159 return "", err 160 } 161 slug, ok := mapping[uuid] 162 if !ok || slug == "" { 163 return "", errors.New("not found") 164 } 165 return slug, nil 166 } 167 168 func extractPayloadConnectionConnectorUUID(payload map[string]interface{}) (string, error) { 169 conn, ok := payload["connection"].(map[string]interface{}) 170 if !ok { 171 return "", errors.New("connection not found") 172 } 173 uuid, ok := conn["connector_uuid"].(string) 174 if !ok { 175 return "", errors.New("connection.connector not found") 176 } 177 return uuid, nil 178 } 179 180 func extractPayloadConnID(payload map[string]interface{}) (int, error) { 181 conn, ok := payload["connection"].(map[string]interface{}) 182 if !ok { 183 return 0, errors.New("connection not found") 184 } 185 connID, ok := conn["id"].(float64) 186 if !ok { 187 return 0, errors.New("connection.id not found") 188 } 189 return int(connID), nil 190 } 191 192 func extractPayloadUserID(payload map[string]interface{}) (int, error) { 193 user, ok := payload["user"].(map[string]interface{}) 194 if !ok { 195 return 0, errors.New("user not found") 196 } 197 id, ok := user["id"].(float64) 198 if !ok { 199 return 0, errors.New("user.id not found") 200 } 201 return int(id), nil 202 } 203 204 func (c *WebhookCall) handleConnectionDeleted() error { 205 connID, err := extractPayloadID(c.Payload) 206 if err != nil { 207 return err 208 } 209 if connID == 0 { 210 return errors.New("no connection.id") 211 } 212 213 msg := "no io.cozy.accounts deleted" 214 if account, _ := findAccount(c.accounts, connID); account != nil { 215 // The account has already been deleted on BI side, so we can skip the 216 // on_delete execution for the konnector. 217 account.ManualCleaning = true 218 if err := couchdb.DeleteDoc(c.Instance, account); err != nil { 219 c.Instance.Logger().WithNamespace("webhook"). 220 Warnf("failed to delete account: %s", err) 221 return err 222 } 223 msg = fmt.Sprintf("account %s ", account.ID()) 224 225 trigger, _ := findTrigger(c.Instance, account) 226 if trigger != nil { 227 jobsSystem := job.System() 228 if err := jobsSystem.DeleteTrigger(c.Instance, trigger.ID()); err != nil { 229 c.Instance.Logger().WithNamespace("webhook"). 230 Errorf("failed to delete trigger: %s", err) 231 } 232 msg += fmt.Sprintf("and trigger %s ", trigger.ID()) 233 } 234 msg += "deleted" 235 } 236 237 userID, _ := extractPayloadIDUser(c.Payload) 238 c.Instance.Logger().WithNamespace("webhook"). 239 Infof("Connection deleted user_id=%d connection_id=%d %s", userID, connID, msg) 240 241 // If the user has no longer any connections on BI, we must remove their 242 // data from BI. 243 api, err := newAPIClient(c.BIurl) 244 if err != nil { 245 return err 246 } 247 nb, err := api.getNumberOfConnections(c.Instance, c.Token) 248 if err != nil { 249 return fmt.Errorf("getNumberOfConnections: %s", err) 250 } 251 if nb == 0 { 252 if err := api.deleteUser(c.Token); err != nil { 253 return fmt.Errorf("deleteUser: %s", err) 254 } 255 if err := c.resetAggregator(); err != nil { 256 return fmt.Errorf("resetAggregator: %s", err) 257 } 258 } 259 return nil 260 } 261 262 func extractPayloadID(payload map[string]interface{}) (int, error) { 263 id, ok := payload["id"].(float64) 264 if !ok { 265 return 0, errors.New("id not found") 266 } 267 return int(id), nil 268 } 269 270 func extractPayloadIDUser(payload map[string]interface{}) (int, error) { 271 id, ok := payload["id_user"].(float64) 272 if !ok { 273 return 0, errors.New("id not found") 274 } 275 return int(id), nil 276 } 277 278 func findAccount(accounts []*account.Account, connID int) (*account.Account, error) { 279 for _, account := range accounts { 280 id := extractAccountConnID(account.Data) 281 if id == connID { 282 return account, nil 283 } 284 } 285 return nil, fmt.Errorf("no account found with the connection id %d", connID) 286 } 287 288 func extractAccountConnID(data map[string]interface{}) int { 289 if data == nil { 290 return 0 291 } 292 auth, ok := data["auth"].(map[string]interface{}) 293 if !ok { 294 return 0 295 } 296 bi, ok := auth["bi"].(map[string]interface{}) 297 if !ok { 298 return 0 299 } 300 connID, _ := bi["connId"].(float64) 301 return int(connID) 302 } 303 304 func findTrigger(inst *instance.Instance, acc *account.Account) (job.Trigger, error) { 305 jobsSystem := job.System() 306 triggers, err := account.GetTriggers(jobsSystem, inst, acc.ID()) 307 if err != nil { 308 return nil, err 309 } 310 if len(triggers) == 0 { 311 return nil, errors.New("no trigger found for this account") 312 } 313 return triggers[0], nil 314 } 315 316 func (c *WebhookCall) resetAggregator() error { 317 aggregator := findAccountByID(c.accounts, aggregatorID) 318 if aggregator != nil { 319 aggregator.Token = "" 320 if err := couchdb.UpdateDoc(c.Instance, aggregator); err != nil { 321 return err 322 } 323 } 324 325 user := findAccountByID(c.accounts, aggregatorUserID) 326 if user != nil { 327 user.UserID = "" 328 if err := couchdb.UpdateDoc(c.Instance, user); err != nil { 329 return err 330 } 331 } 332 333 return nil 334 } 335 336 func findAccountByID(accounts []*account.Account, id string) *account.Account { 337 for _, account := range accounts { 338 if id == account.DocID { 339 return account 340 } 341 } 342 return nil 343 } 344 345 func (c *WebhookCall) createAccountAndTrigger(konn *app.KonnManifest, connectionID int) (*account.Account, job.Trigger, error) { 346 acc := couchdb.JSONDoc{Type: consts.Accounts} 347 data := map[string]interface{}{ 348 "auth": map[string]interface{}{ 349 "bi": map[string]interface{}{ 350 "connId": connectionID, 351 }, 352 }, 353 } 354 rels := map[string]interface{}{ 355 "parent": map[string]interface{}{ 356 "data": map[string]interface{}{ 357 "_id": aggregatorID, 358 "_type": consts.Accounts, 359 }, 360 }, 361 } 362 acc.M = map[string]interface{}{ 363 "account_type": konn.Slug(), 364 "data": data, 365 "relationships": rels, 366 } 367 368 account.Encrypt(acc) 369 account.ComputeName(acc) 370 371 cm := metadata.New() 372 cm.CreatedByApp = konn.Slug() 373 cm.CreatedByAppVersion = konn.Version() 374 cm.UpdatedByApps = []*metadata.UpdatedByAppEntry{ 375 { 376 Slug: konn.Slug(), 377 Version: konn.Version(), 378 Date: cm.UpdatedAt, 379 }, 380 } 381 // This is not the expected type for a JSON doc but it should work since it 382 // will be marshalled when saved. 383 acc.M["cozyMetadata"] = cm 384 385 if err := couchdb.CreateDoc(c.Instance, &acc); err != nil { 386 return nil, nil, err 387 } 388 389 trigger, err := konn.CreateTrigger(c.Instance, acc.ID(), "") 390 if err != nil { 391 return nil, nil, err 392 } 393 394 created := &account.Account{ 395 DocID: acc.ID(), 396 DocRev: acc.Rev(), 397 AccountType: konn.Slug(), 398 Data: data, 399 Relationships: rels, 400 } 401 return created, trigger, nil 402 } 403 404 func (c *WebhookCall) handleAccountEnabledOrDisabled() error { 405 connID, err := extractPayloadIDConnection(c.Payload) 406 if err != nil { 407 return err 408 } 409 if connID == 0 { 410 return errors.New("no id_connection") 411 } 412 413 var trigger job.Trigger 414 account, err := findAccount(c.accounts, connID) 415 if err != nil { 416 api, err := newAPIClient(c.BIurl) 417 if err != nil { 418 return err 419 } 420 uuid, err := api.getConnectorUUID(connID, c.Token) 421 if err != nil { 422 return err 423 } 424 slug, err := mapUUIDToSlug(uuid) 425 if err != nil { 426 return err 427 } 428 konn, err := app.GetKonnectorBySlug(c.Instance, slug) 429 if err != nil { 430 return err 431 } 432 account, trigger, err = c.createAccountAndTrigger(konn, connID) 433 if err != nil { 434 return err 435 } 436 } else { 437 trigger, err = findTrigger(c.Instance, account) 438 if err != nil { 439 return err 440 } 441 } 442 443 return c.fireTrigger(trigger, account) 444 } 445 446 func extractPayloadIDConnection(payload map[string]interface{}) (int, error) { 447 id, ok := payload["id_connection"].(float64) 448 if !ok { 449 return 0, errors.New("id_connection not found") 450 } 451 return int(id), nil 452 } 453 454 func (c *WebhookCall) mustExecuteKonnector(trigger job.Trigger) bool { 455 return payloadHasAccounts(c.Payload) || lastExecNotSuccessful(c.Instance, trigger) 456 } 457 458 func payloadHasAccounts(payload map[string]interface{}) bool { 459 conn, ok := payload["connection"].(map[string]interface{}) 460 if !ok { 461 return false 462 } 463 accounts, ok := conn["accounts"].([]interface{}) 464 if !ok { 465 return false 466 } 467 return len(accounts) > 0 468 } 469 470 func lastExecNotSuccessful(inst *instance.Instance, trigger job.Trigger) bool { 471 lastJobs, err := job.GetJobs(inst, trigger.ID(), 1) 472 if err != nil || len(lastJobs) == 0 { 473 return true 474 } 475 return lastJobs[0].State != job.Done 476 } 477 478 func (c *WebhookCall) fireTrigger(trigger job.Trigger, account *account.Account) error { 479 req := trigger.Infos().JobRequest() 480 var msg map[string]interface{} 481 if err := json.Unmarshal(req.Message, &msg); err == nil { 482 msg["bi_webhook"] = true 483 msg["event"] = string(c.Event) 484 if updated, err := json.Marshal(msg); err == nil { 485 req.Message = updated 486 } 487 } 488 if raw, err := json.Marshal(c.Payload); err == nil { 489 req.Payload = raw 490 } 491 j, err := job.System().PushJob(c.Instance, req) 492 if err == nil { 493 c.Instance.Logger().WithNamespace("webhook"). 494 Debugf("Push job %s (account: %s - trigger: %s)", j.ID(), account.ID(), trigger.ID()) 495 } 496 return err 497 } 498 499 func (c *WebhookCall) copyLastUpdate(account *account.Account, konn *app.KonnManifest) error { 500 conn, ok := c.Payload["connection"].(map[string]interface{}) 501 if !ok { 502 return errors.New("no connection") 503 } 504 lastUpdate, ok := conn["last_update"].(string) 505 if !ok { 506 return errors.New("no connection.last_update") 507 } 508 if account.Data == nil { 509 return fmt.Errorf("no data in account %s", account.ID()) 510 } 511 auth, ok := account.Data["auth"].(map[string]interface{}) 512 if !ok { 513 return fmt.Errorf("no data.auth in account %s", account.ID()) 514 } 515 bi, ok := auth["bi"].(map[string]interface{}) 516 if !ok { 517 return fmt.Errorf("no data.auth.bi in account %s", account.ID()) 518 } 519 bi["lastUpdate"] = lastUpdate 520 521 if account.Metadata == nil { 522 cm := metadata.New() 523 cm.CreatedByApp = konn.Slug() 524 cm.CreatedByAppVersion = konn.Version() 525 cm.UpdatedByApps = []*metadata.UpdatedByAppEntry{ 526 { 527 Slug: konn.Slug(), 528 Version: konn.Version(), 529 Date: cm.UpdatedAt, 530 }, 531 } 532 account.Metadata = cm 533 } 534 535 err := couchdb.UpdateDoc(c.Instance, account) 536 if err == nil { 537 c.Instance.Logger().WithNamespace("webhook"). 538 Debugf("Set lastUpdate to %s (account :%s)", lastUpdate, account.ID()) 539 } 540 return err 541 }