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 }