github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/bitwarden/organization.go (about)

     1  package bitwarden
     2  
     3  import (
     4  	"encoding/json"
     5  	"net/http"
     6  	"net/url"
     7  	"time"
     8  
     9  	"github.com/cozy/cozy-stack/model/bitwarden"
    10  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    11  	"github.com/cozy/cozy-stack/model/contact"
    12  	"github.com/cozy/cozy-stack/model/instance"
    13  	"github.com/cozy/cozy-stack/model/permission"
    14  	"github.com/cozy/cozy-stack/pkg/consts"
    15  	"github.com/cozy/cozy-stack/pkg/couchdb"
    16  	"github.com/cozy/cozy-stack/pkg/metadata"
    17  	"github.com/cozy/cozy-stack/web/middlewares"
    18  	"github.com/gofrs/uuid/v5"
    19  	"github.com/labstack/echo/v4"
    20  )
    21  
    22  // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/organizationCreateRequest.ts
    23  type organizationRequest struct {
    24  	Name           string `json:"name"`
    25  	Key            string `json:"key"`
    26  	CollectionName string `json:"collectionName"`
    27  }
    28  
    29  func (r *organizationRequest) toOrganization(inst *instance.Instance) *bitwarden.Organization {
    30  	md := metadata.New()
    31  	md.DocTypeVersion = bitwarden.DocTypeVersion
    32  	settings, err := inst.SettingsDocument()
    33  	if err != nil {
    34  		settings = &couchdb.JSONDoc{M: map[string]interface{}{}}
    35  	}
    36  	email, _ := settings.M["email"].(string)
    37  	name, _ := settings.M["public_name"].(string)
    38  	return &bitwarden.Organization{
    39  		Name: r.Name,
    40  		Members: map[string]bitwarden.OrgMember{
    41  			inst.Domain: {
    42  				UserID: inst.ID(),
    43  				Email:  email,
    44  				Name:   name,
    45  				OrgKey: r.Key,
    46  				Status: bitwarden.OrgMemberConfirmed,
    47  				Owner:  true,
    48  			},
    49  		},
    50  		Collection: bitwarden.Collection{
    51  			Name: r.CollectionName,
    52  		},
    53  		Metadata: *md,
    54  	}
    55  }
    56  
    57  // https://github.com/bitwarden/jslib/blob/master/common/src/models/response/profileOrganizationResponse.ts
    58  type organizationResponse struct {
    59  	ID             string  `json:"Id"`
    60  	Identifier     *string `json:"Identifier"`
    61  	Name           string  `json:"Name"`
    62  	Key            string  `json:"Key"`
    63  	Email          string  `json:"BillingEmail"`
    64  	Plan           string  `json:"Plan"`
    65  	PlanType       int     `json:"PlanType"`
    66  	Seats          int     `json:"Seats"`
    67  	MaxCollections int     `json:"MaxCollections"`
    68  	MaxStorage     int     `json:"MaxStorageGb"`
    69  	SelfHost       bool    `json:"SelfHost"`
    70  	Use2fa         bool    `json:"Use2fa"`
    71  	UseDirectory   bool    `json:"UseDirectory"`
    72  	UseEvents      bool    `json:"UseEvents"`
    73  	UseGroups      bool    `json:"UseGroups"`
    74  	UseTotp        bool    `json:"UseTotp"`
    75  	UseAPI         bool    `json:"UseApi"`
    76  	UsePolicies    bool    `json:"UsePolicies"`
    77  	UseSSO         bool    `json:"UseSSO"`
    78  	UseResetPass   bool    `json:"UseResetPassword"`
    79  	HasKeys        bool    `json:"HasPublicAndPrivateKeys"`
    80  	ResetPass      bool    `json:"ResetPasswordEnrolled"`
    81  	Premium        bool    `json:"UsersGetPremium"`
    82  	Enabled        bool    `json:"Enabled"`
    83  	Status         int     `json:"Status"`
    84  	Type           int     `json:"Type"`
    85  	Object         string  `json:"Object"`
    86  }
    87  
    88  func newOrganizationResponse(inst *instance.Instance, org *bitwarden.Organization) *organizationResponse {
    89  	m := org.Members[inst.Domain]
    90  	typ := 2 // User
    91  	if m.Owner {
    92  		typ = 0 // Owner
    93  	}
    94  	email := inst.PassphraseSalt()
    95  	return &organizationResponse{
    96  		ID:             org.ID(),
    97  		Identifier:     nil, // Not supported by us
    98  		Name:           org.Name,
    99  		Key:            m.OrgKey,
   100  		Email:          string(email),
   101  		Plan:           "TeamsAnnually",
   102  		PlanType:       9,  // TeamsAnnually plan
   103  		Seats:          10, // The value doesn't matter
   104  		MaxCollections: 1,
   105  		MaxStorage:     1,
   106  		SelfHost:       true,
   107  		Use2fa:         true,
   108  		UseDirectory:   false,
   109  		UseEvents:      false,
   110  		UseGroups:      false,
   111  		UseTotp:        true,
   112  		UseAPI:         false,
   113  		UsePolicies:    false,
   114  		UseSSO:         false,
   115  		UseResetPass:   false,
   116  		HasKeys:        false, // The public/private keys are used for the Admin Reset Password feature, not implemented by us
   117  		ResetPass:      false,
   118  		Premium:        true,
   119  		Enabled:        true,
   120  		Status:         int(m.Status),
   121  		Type:           typ,
   122  		Object:         "profileOrganization",
   123  	}
   124  }
   125  
   126  // https://github.com/bitwarden/jslib/blob/master/common/src/models/response/collectionResponse.ts
   127  // We deviate from the Bitwarden's protocol by adding ReadOnly field
   128  // On Bitwarden's protocol this field is present only on collectionDetailsResponse
   129  // but we merged both structs in a single one
   130  // Bitwarden app uses this struct only for exporting ciphers, so they don't need ReadOnly member
   131  // Cozy app uses this struct for realtime syncing and so it needs to have the ReadOnly state
   132  type collectionResponse struct {
   133  	ID             string `json:"Id"`
   134  	OrganizationID string `json:"OrganizationId"`
   135  	Name           string `json:"Name"`
   136  	Object         string `json:"Object"`
   137  	ReadOnly       bool   `json:"ReadOnly"`
   138  }
   139  
   140  func newCollectionResponse(inst *instance.Instance, org *bitwarden.Organization, coll *bitwarden.Collection) *collectionResponse {
   141  	m := org.Members[inst.Domain]
   142  
   143  	return &collectionResponse{
   144  		ID:             coll.ID(),
   145  		OrganizationID: org.ID(),
   146  		Name:           coll.Name,
   147  		Object:         "collection",
   148  		ReadOnly:       m.ReadOnly,
   149  	}
   150  }
   151  
   152  // CreateOrganization is the route used to create an organization (with a
   153  // collection).
   154  func CreateOrganization(c echo.Context) error {
   155  	inst := middlewares.GetInstance(c)
   156  	if err := middlewares.AllowWholeType(c, permission.POST, consts.BitwardenOrganizations); err != nil {
   157  		return c.JSON(http.StatusUnauthorized, echo.Map{
   158  			"error": "invalid token",
   159  		})
   160  	}
   161  
   162  	var req organizationRequest
   163  	if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil {
   164  		return c.JSON(http.StatusBadRequest, echo.Map{
   165  			"error": "invalid JSON",
   166  		})
   167  	}
   168  	if req.Name == "" {
   169  		return c.JSON(http.StatusBadRequest, echo.Map{
   170  			"error": "missing name",
   171  		})
   172  	}
   173  
   174  	org := req.toOrganization(inst)
   175  	collID, err := uuid.NewV7()
   176  	if err != nil {
   177  		return c.JSON(http.StatusInternalServerError, echo.Map{
   178  			"error": err.Error(),
   179  		})
   180  	}
   181  	org.Collection.DocID = collID.String()
   182  	if err := couchdb.CreateDoc(inst, org); err != nil {
   183  		return c.JSON(http.StatusInternalServerError, echo.Map{
   184  			"error": err.Error(),
   185  		})
   186  	}
   187  
   188  	_ = settings.UpdateRevisionDate(inst, nil)
   189  	res := newOrganizationResponse(inst, org)
   190  	return c.JSON(http.StatusOK, res)
   191  }
   192  
   193  // GetOrganization is the route for getting information about an organization.
   194  func GetOrganization(c echo.Context) error {
   195  	inst := middlewares.GetInstance(c)
   196  
   197  	if err := middlewares.AllowWholeType(c, permission.GET, consts.BitwardenOrganizations); err != nil {
   198  		return c.JSON(http.StatusUnauthorized, echo.Map{
   199  			"error": "invalid token",
   200  		})
   201  	}
   202  
   203  	id := c.Param("id")
   204  	if id == "" {
   205  		return c.JSON(http.StatusNotFound, echo.Map{
   206  			"error": "missing id",
   207  		})
   208  	}
   209  
   210  	org := &bitwarden.Organization{}
   211  	if err := couchdb.GetDoc(inst, consts.BitwardenOrganizations, id, org); err != nil {
   212  		if couchdb.IsNotFoundError(err) {
   213  			return c.JSON(http.StatusNotFound, echo.Map{
   214  				"error": "not found",
   215  			})
   216  		}
   217  		return c.JSON(http.StatusInternalServerError, echo.Map{
   218  			"error": err.Error(),
   219  		})
   220  	}
   221  
   222  	res := newOrganizationResponse(inst, org)
   223  	return c.JSON(http.StatusOK, res)
   224  }
   225  
   226  type collectionsList struct {
   227  	Data   []*collectionResponse `json:"Data"`
   228  	Object string                `json:"Object"`
   229  }
   230  
   231  // GetCollections is the route for getting information about the collections
   232  // inside an organization.
   233  func GetCollections(c echo.Context) error {
   234  	inst := middlewares.GetInstance(c)
   235  
   236  	if err := middlewares.AllowWholeType(c, permission.GET, consts.BitwardenOrganizations); err != nil {
   237  		return c.JSON(http.StatusUnauthorized, echo.Map{
   238  			"error": "invalid token",
   239  		})
   240  	}
   241  
   242  	id := c.Param("id")
   243  	if id == "" {
   244  		return c.JSON(http.StatusNotFound, echo.Map{
   245  			"error": "missing id",
   246  		})
   247  	}
   248  
   249  	org := &bitwarden.Organization{}
   250  	if err := couchdb.GetDoc(inst, consts.BitwardenOrganizations, id, org); err != nil {
   251  		if couchdb.IsNotFoundError(err) {
   252  			return c.JSON(http.StatusNotFound, echo.Map{
   253  				"error": "not found",
   254  			})
   255  		}
   256  		return c.JSON(http.StatusInternalServerError, echo.Map{
   257  			"error": err.Error(),
   258  		})
   259  	}
   260  
   261  	coll := newCollectionResponse(inst, org, &org.Collection)
   262  	res := &collectionsList{Object: "list"}
   263  	res.Data = []*collectionResponse{coll}
   264  	return c.JSON(http.StatusOK, res)
   265  }
   266  
   267  // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/passwordVerificationRequest.ts
   268  type passwordVerificationRequest struct {
   269  	Hash string `json:"masterPasswordHash"`
   270  }
   271  
   272  // DeleteOrganization is the route for deleting an organization by its owner.
   273  func DeleteOrganization(c echo.Context) error {
   274  	inst := middlewares.GetInstance(c)
   275  	if err := middlewares.AllowWholeType(c, permission.DELETE, consts.BitwardenOrganizations); err != nil {
   276  		return c.JSON(http.StatusUnauthorized, echo.Map{
   277  			"error": "invalid token",
   278  		})
   279  	}
   280  
   281  	var verification passwordVerificationRequest
   282  	if err := json.NewDecoder(c.Request().Body).Decode(&verification); err != nil {
   283  		return c.JSON(http.StatusBadRequest, echo.Map{
   284  			"error": "invalid JSON",
   285  		})
   286  	}
   287  	if err := instance.CheckPassphrase(inst, []byte(verification.Hash)); err != nil {
   288  		return c.JSON(http.StatusUnauthorized, echo.Map{
   289  			"error": "invalid password",
   290  		})
   291  	}
   292  
   293  	id := c.Param("id")
   294  	if id == "" {
   295  		return c.JSON(http.StatusNotFound, echo.Map{
   296  			"error": "missing id",
   297  		})
   298  	}
   299  
   300  	org := &bitwarden.Organization{}
   301  	if err := couchdb.GetDoc(inst, consts.BitwardenOrganizations, id, org); err != nil {
   302  		if couchdb.IsNotFoundError(err) {
   303  			return c.JSON(http.StatusNotFound, echo.Map{
   304  				"error": "not found",
   305  			})
   306  		}
   307  		return c.JSON(http.StatusInternalServerError, echo.Map{
   308  			"error": err.Error(),
   309  		})
   310  	}
   311  
   312  	if m := org.Members[inst.Domain]; !m.Owner {
   313  		return c.JSON(http.StatusUnauthorized, echo.Map{
   314  			"error": "only the Owner can call this endpoint",
   315  		})
   316  	}
   317  
   318  	if err := org.Delete(inst); err != nil {
   319  		return c.JSON(http.StatusInternalServerError, echo.Map{
   320  			"error": err.Error(),
   321  		})
   322  	}
   323  
   324  	_ = settings.UpdateRevisionDate(inst, nil)
   325  	return c.NoContent(http.StatusOK)
   326  }
   327  
   328  // https://github.com/bitwarden/jslib/blob/master/common/src/models/response/organizationUserResponse.ts
   329  type userDetailsResponse struct {
   330  	ID        string                    `json:"Id"`
   331  	UserID    string                    `json:"UserId"`
   332  	Type      int                       `json:"Type"`
   333  	Status    bitwarden.OrgMemberStatus `json:"Status"`
   334  	AccessAll bool                      `json:"AccessAll"`
   335  	Name      string                    `json:"Name"`
   336  	Email     string                    `json:"Email"`
   337  	Object    string                    `json:"Object"`
   338  }
   339  
   340  func newUserDetailsResponse(m *bitwarden.OrgMember) *userDetailsResponse {
   341  	typ := 2 // User
   342  	if m.Owner {
   343  		typ = 0 // Owner
   344  	}
   345  	return &userDetailsResponse{
   346  		ID:        m.UserID,
   347  		UserID:    m.UserID,
   348  		Type:      typ,
   349  		Status:    m.Status,
   350  		AccessAll: true,
   351  		Name:      m.Name,
   352  		Email:     m.Email,
   353  		Object:    "organizationUserUserDetails",
   354  	}
   355  }
   356  
   357  type userDetailsList struct {
   358  	Data   []*userDetailsResponse `json:"Data"`
   359  	Object string                 `json:"Object"`
   360  }
   361  
   362  // ListOrganizationUser is the route for listing users inside an organization.
   363  func ListOrganizationUser(c echo.Context) error {
   364  	inst := middlewares.GetInstance(c)
   365  	if err := middlewares.AllowWholeType(c, permission.GET, consts.BitwardenOrganizations); err != nil {
   366  		return c.JSON(http.StatusUnauthorized, echo.Map{
   367  			"error": "invalid token",
   368  		})
   369  	}
   370  
   371  	id := c.Param("id")
   372  	if id == "" {
   373  		return c.JSON(http.StatusNotFound, echo.Map{
   374  			"error": "missing id",
   375  		})
   376  	}
   377  
   378  	org := &bitwarden.Organization{}
   379  	if err := couchdb.GetDoc(inst, consts.BitwardenOrganizations, id, org); err != nil {
   380  		if couchdb.IsNotFoundError(err) {
   381  			return c.JSON(http.StatusNotFound, echo.Map{
   382  				"error": "not found",
   383  			})
   384  		}
   385  		return c.JSON(http.StatusInternalServerError, echo.Map{
   386  			"error": err.Error(),
   387  		})
   388  	}
   389  
   390  	list := &userDetailsList{Object: "list"}
   391  	for _, m := range org.Members {
   392  		list.Data = append(list.Data, newUserDetailsResponse(&m))
   393  	}
   394  	return c.JSON(http.StatusOK, list)
   395  }
   396  
   397  // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/organizationUserConfirmRequest.ts
   398  type userConfirmRequest struct {
   399  	Key string `json:"key"`
   400  }
   401  
   402  // ConfirmUser is the route to confirm a user in an organization. It takes the
   403  // organization key encrypted with the public key of this user as input.
   404  func ConfirmUser(c echo.Context) error {
   405  	inst := middlewares.GetInstance(c)
   406  	if err := middlewares.AllowWholeType(c, permission.POST, consts.BitwardenOrganizations); err != nil {
   407  		return c.JSON(http.StatusUnauthorized, echo.Map{
   408  			"error": "invalid token",
   409  		})
   410  	}
   411  
   412  	id := c.Param("id")
   413  	if id == "" {
   414  		return c.JSON(http.StatusNotFound, echo.Map{
   415  			"error": "missing id",
   416  		})
   417  	}
   418  	org := &bitwarden.Organization{}
   419  	if err := couchdb.GetDoc(inst, consts.BitwardenOrganizations, id, org); err != nil {
   420  		if couchdb.IsNotFoundError(err) {
   421  			return c.JSON(http.StatusNotFound, echo.Map{
   422  				"error": "not found",
   423  			})
   424  		}
   425  		return c.JSON(http.StatusInternalServerError, echo.Map{
   426  			"error": err.Error(),
   427  		})
   428  	}
   429  	if m := org.Members[inst.Domain]; !m.Owner {
   430  		return c.JSON(http.StatusUnauthorized, echo.Map{
   431  			"error": "only the Owner can call this endpoint",
   432  		})
   433  	}
   434  
   435  	var confirm userConfirmRequest
   436  	err := json.NewDecoder(c.Request().Body).Decode(&confirm)
   437  	if err != nil || confirm.Key == "" {
   438  		return c.JSON(http.StatusBadRequest, echo.Map{
   439  			"error": "invalid JSON",
   440  		})
   441  	}
   442  
   443  	userID := c.Param("user-id")
   444  	var bwContact bitwarden.Contact
   445  	if err := couchdb.GetDoc(inst, consts.BitwardenContacts, userID, &bwContact); err != nil {
   446  		if couchdb.IsNotFoundError(err) {
   447  			return c.JSON(http.StatusNotFound, echo.Map{
   448  				"error": "not found",
   449  			})
   450  		}
   451  		return c.JSON(http.StatusInternalServerError, echo.Map{
   452  			"error": err.Error(),
   453  		})
   454  	}
   455  
   456  	found := false
   457  	for domain, member := range org.Members {
   458  		if member.UserID != userID {
   459  			continue
   460  		}
   461  		if member.Status == bitwarden.OrgMemberAccepted {
   462  			member.Status = bitwarden.OrgMemberConfirmed
   463  		} else if member.Status != bitwarden.OrgMemberInvited {
   464  			return c.JSON(http.StatusBadRequest, echo.Map{
   465  				"error": "User in invalid state",
   466  			})
   467  		}
   468  		found = true
   469  		member.OrgKey = confirm.Key
   470  		org.Members[domain] = member
   471  	}
   472  	if !found {
   473  		card, err := contact.FindByEmail(inst, bwContact.Email)
   474  		if err != nil {
   475  			return c.JSON(http.StatusInternalServerError, echo.Map{
   476  				"error": err.Error(),
   477  			})
   478  		}
   479  		domain := card.PrimaryCozyURL()
   480  		if domain == "" {
   481  			return c.JSON(http.StatusInternalServerError, echo.Map{
   482  				"error": "Unknown Cozy URL for this user",
   483  			})
   484  		}
   485  		if u, err := url.Parse(domain); err == nil {
   486  			domain = u.Host
   487  		}
   488  		org.Members[domain] = bitwarden.OrgMember{
   489  			UserID:   bwContact.UserID,
   490  			Email:    bwContact.Email,
   491  			Name:     card.PrimaryName(),
   492  			OrgKey:   confirm.Key,
   493  			Status:   bitwarden.OrgMemberInvited,
   494  			Owner:    false,
   495  			ReadOnly: true, // it will be overwritten when the user accepts the sharing
   496  		}
   497  	}
   498  
   499  	if !bwContact.Confirmed {
   500  		bwContact.Confirmed = true
   501  		bwContact.Metadata.UpdatedAt = time.Now()
   502  		if err := couchdb.UpdateDoc(inst, &bwContact); err != nil {
   503  			return c.JSON(http.StatusInternalServerError, echo.Map{
   504  				"error": err.Error(),
   505  			})
   506  		}
   507  	}
   508  
   509  	if err := couchdb.UpdateDoc(inst, org); err != nil {
   510  		return c.JSON(http.StatusInternalServerError, echo.Map{
   511  			"error": err.Error(),
   512  		})
   513  	}
   514  
   515  	return c.NoContent(http.StatusOK)
   516  }
   517  
   518  // GetPublicKey returns the public key of a user.
   519  func GetPublicKey(c echo.Context) error {
   520  	inst := middlewares.GetInstance(c)
   521  	if err := middlewares.AllowWholeType(c, permission.GET, consts.BitwardenOrganizations); err != nil {
   522  		return c.JSON(http.StatusUnauthorized, echo.Map{
   523  			"error": "invalid token",
   524  		})
   525  	}
   526  
   527  	id := c.Param("id")
   528  	if id == "" {
   529  		return c.JSON(http.StatusNotFound, echo.Map{
   530  			"error": "missing id",
   531  		})
   532  	}
   533  	var contact bitwarden.Contact
   534  	if err := couchdb.GetDoc(inst, consts.BitwardenContacts, id, &contact); err != nil {
   535  		if couchdb.IsNotFoundError(err) {
   536  			return c.JSON(http.StatusNotFound, echo.Map{
   537  				"error": "not found",
   538  			})
   539  		}
   540  		return c.JSON(http.StatusInternalServerError, echo.Map{
   541  			"error": err.Error(),
   542  		})
   543  	}
   544  
   545  	return c.JSON(http.StatusOK, echo.Map{
   546  		"UserId":    id,
   547  		"PublicKey": contact.PublicKey,
   548  	})
   549  }