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  }