diff --git a/cozy.example.yaml b/cozy.example.yaml index 9644b9fcdf9..1f50fc8c1e6 100644 --- a/cozy.example.yaml +++ b/cozy.example.yaml @@ -719,6 +719,21 @@ rabbitmq: delivery_limit: 5 bindings: - domain.user.deleted + - name: stack.b2b.group.lifecycle + declare: true + declare_dlx: true + declare_dlq: true + dlx_name: stack.b2b.dlx + dlq_name: stack.dead.letter.b2b.group.lifecycle + dl_routing_key: b2b.group.dead + prefetch: 8 + delivery_limit: 5 + bindings: + - b2b.group.created + - b2b.group.updated + - b2b.group.deleted + - b2b.group.member.added + - b2b.group.member.removed - name: stack.app.commands.queue declare: true declare_dlx: true diff --git a/docs/rabbitmq.md b/docs/rabbitmq.md index 46a81c803d4..57a787cd209 100644 --- a/docs/rabbitmq.md +++ b/docs/rabbitmq.md @@ -123,6 +123,21 @@ rabbitmq: delivery_limit: 5 bindings: - domain.user.deleted + - name: stack.b2b.group.lifecycle + declare: true + declare_dlx: true + declare_dlq: true + dlx_name: stack.b2b.dlx + dlq_name: stack.dead.letter.b2b.group.lifecycle + dl_routing_key: b2b.group.dead + prefetch: 8 + delivery_limit: 5 + bindings: + - b2b.group.created + - b2b.group.updated + - b2b.group.deleted + - b2b.group.member.added + - b2b.group.member.removed - name: stack.app.commands.queue declare: true declare_dlx: true @@ -313,6 +328,29 @@ Example payload for `b2b/domain.user.deleted`: The delete handler removes the single matching contact found by `internalEmail` when `metadata.external` is true. +Example payload for `b2b.group.created`: + +```json +{ + "timestamp": "2026-06-16T10:30:00.000Z", + "organizationId": "org123", + "id": "engineering", + "name": "engineering", + "description": "Engineering team", + "color": "#3366FF", + "createdAt": "2026-06-16T10:30:00.000Z", + "members": [] +} +``` + +The Stack consumes `b2b.group.created`, `b2b.group.updated`, +`b2b.group.deleted`, `b2b.group.member.added`, and +`b2b.group.member.removed` from one lifecycle queue. Groups are replicated as +managed `io.cozy.contacts.groups` documents in every instance with the matching +`org_id`. Group membership is stored on managed external contacts via +`relationships.groups.data`; existing generated-ID external contacts are reused +by matching email or Cozy URL. + Example payload for `user.phone.updated`: ```json diff --git a/model/instance/init.go b/model/instance/init.go index fcb9923c036..08940738cc8 100644 --- a/model/instance/init.go +++ b/model/instance/init.go @@ -39,6 +39,14 @@ func ListByOrgDomain(orgDomain string) ([]*Instance, error) { return service.ListByOrgDomain(orgDomain) } +// ListByOrgID finds instances of a given organization from the organization +// identifier by using CouchDB. +// +// Deprecated: Use [InstanceService.ListByOrgID] instead. +func ListByOrgID(orgID string) ([]*Instance, error) { + return service.ListByOrgID(orgID) +} + // Update saves the changes in CouchDB. // // Deprecated: Use [InstanceService.Update] instead. diff --git a/model/instance/lifecycle/get.go b/model/instance/lifecycle/get.go index 682acb9f747..36600f96329 100644 --- a/model/instance/lifecycle/get.go +++ b/model/instance/lifecycle/get.go @@ -55,3 +55,9 @@ func GetInstance(domain string) (*instance.Instance, error) { func ListOrgInstances(orgDomain string) ([]*instance.Instance, error) { return instance.ListByOrgDomain(orgDomain) } + +// ListOrgInstancesByID retrieves all the instances of an organization by its +// organization identifier. +func ListOrgInstancesByID(orgID string) ([]*instance.Instance, error) { + return instance.ListByOrgID(orgID) +} diff --git a/model/instance/service.go b/model/instance/service.go index e5276a9685b..2507b95bc6e 100644 --- a/model/instance/service.go +++ b/model/instance/service.go @@ -15,6 +15,8 @@ type InstanceService struct { logger logger.Logger } +const orgInstanceListPageSize = 1000 + func NewService(logger logger.Logger) *InstanceService { return &InstanceService{ logger: logger, @@ -55,16 +57,43 @@ func (s *InstanceService) Get(domain string) (*Instance, error) { } func (s *InstanceService) ListByOrgDomain(orgDomain string) ([]*Instance, error) { + return s.listByOrgField("by-orgdomain", "org_domain", orgDomain) +} + +func (s *InstanceService) ListByOrgID(orgID string) ([]*Instance, error) { + return s.listByOrgField("by-orgid", "org_id", orgID) +} + +func (s *InstanceService) listByOrgField(indexName, fieldName, value string) ([]*Instance, error) { var docs []*Instance - req := &couchdb.FindRequest{ - UseIndex: "by-orgdomain", - Selector: mango.Equal("org_domain", orgDomain), - } - err := couchdb.FindDocs(prefixer.GlobalPrefixer, consts.Instances, req, &docs) - if err != nil { - return nil, err + var bookmark string + for { + var page []*Instance + req := &couchdb.FindRequest{ + UseIndex: indexName, + Selector: mango.Equal(fieldName, value), + Limit: orgInstanceListPageSize, + Bookmark: bookmark, + } + res, err := couchdb.FindDocsRaw(prefixer.GlobalPrefixer, consts.Instances, req, &page) + if err != nil { + return nil, err + } + for _, inst := range page { + if inst != nil { + docs = append(docs, inst) + } + } + + nextBookmark := "" + if res != nil { + nextBookmark = res.Bookmark + } + if len(page) < orgInstanceListPageSize || nextBookmark == "" || nextBookmark == bookmark { + return docs, nil + } + bookmark = nextBookmark } - return docs, nil } // Update saves the changes in CouchDB. diff --git a/model/orgdirectory/contact_patch.go b/model/orgdirectory/contact_patch.go new file mode 100644 index 00000000000..194a687f93f --- /dev/null +++ b/model/orgdirectory/contact_patch.go @@ -0,0 +1,107 @@ +package orgdirectory + +import ( + "fmt" + "strings" + + "github.com/cozy/cozy-stack/model/contact" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/pkg/utils" +) + +// ContactPatch describes a B2B contact replica to create or update. +type ContactPatch struct { + OrganizationID string + Username string + Email string + FirstName string + LastName string + WorkplaceFQDN string + Name string + CozyURL string + Phone string +} + +func (patch ContactPatch) validate() error { + if strings.TrimSpace(patch.Email) == "" { + return fmt.Errorf("contact missing email") + } + return nil +} + +func (patch ContactPatch) shouldSkipOwn(inst *instance.Instance) bool { + if patch.WorkplaceFQDN != "" && inst.HasDomain(utils.ExtractInstanceHost(patch.WorkplaceFQDN)) { + return true + } + if patch.Email == "" { + return false + } + email, err := inst.SettingsEMail() + if err != nil { + return false + } + return strings.EqualFold(strings.TrimSpace(email), patch.Email) +} + +func (patch ContactPatch) displayName() string { + if patch.Name != "" { + return patch.Name + } + name := strings.TrimSpace(patch.FirstName + " " + patch.LastName) + if name != "" { + return name + } + if patch.Username != "" { + return patch.Username + } + if patch.Email != "" { + parts := strings.SplitN(patch.Email, "@", 2) + return parts[0] + } + return "" +} + +func contactPatchFromMember(organizationID string, member GroupMember) ContactPatch { + workplaceFQDN := strings.TrimSpace(member.WorkplaceFQDN) + return ContactPatch{ + OrganizationID: strings.TrimSpace(organizationID), + Username: strings.TrimSpace(member.Username), + Email: strings.TrimSpace(member.Email), + FirstName: strings.TrimSpace(member.FirstName), + LastName: strings.TrimSpace(member.LastName), + WorkplaceFQDN: workplaceFQDN, + CozyURL: cozyURLFromWorkplaceFQDN(workplaceFQDN), + } +} + +func contactPatchFromContact(organizationID string, contactDoc *contact.Contact) ContactPatch { + meta := directoryMetadata(&contactDoc.JSONDoc) + patch := ContactPatch{OrganizationID: organizationID} + patch.Username = meta.Username + patch.Email = meta.Email + patch.WorkplaceFQDN = meta.WorkplaceFQDN + patch.CozyURL = contactDoc.PrimaryCozyURL() + if patch.CozyURL == "" { + patch.CozyURL = cozyURLFromWorkplaceFQDN(patch.WorkplaceFQDN) + } + patch.Name = primaryContactName(contactDoc) + patch.Phone = contactDoc.PrimaryPhoneNumber() + return patch +} + +func primaryContactName(contactDoc *contact.Contact) string { + name := contactDoc.PrimaryName() + if name != "" { + return name + } + displayName, _ := contactDoc.M["displayName"].(string) + return displayName +} + +func cozyURLFromWorkplaceFQDN(workplaceFQDN string) string { + domain := utils.ExtractInstanceHost(workplaceFQDN) + if domain == "" { + return "" + } + return "https://" + domain +} diff --git a/model/orgdirectory/groups.go b/model/orgdirectory/groups.go new file mode 100644 index 00000000000..47db10a16d7 --- /dev/null +++ b/model/orgdirectory/groups.go @@ -0,0 +1,592 @@ +package orgdirectory + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/cozy/cozy-stack/model/contact" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" +) + +// GroupCreatedMessage is the payload for b2b.group.created. +type GroupCreatedMessage struct { + Timestamp time.Time `json:"timestamp"` + OrganizationID string `json:"organizationId"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + CreatedAt time.Time `json:"createdAt"` + Members []GroupMember `json:"members"` +} + +// GroupUpdatedMessage is the payload for b2b.group.updated. +type GroupUpdatedMessage struct { + Timestamp time.Time `json:"timestamp"` + OrganizationID string `json:"organizationId"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` +} + +// GroupDeletedMessage is the payload for b2b.group.deleted. +type GroupDeletedMessage struct { + Timestamp time.Time `json:"timestamp"` + OrganizationID string `json:"organizationId"` + ID string `json:"id"` +} + +// GroupMembersMessage is the payload for b2b.group.member.added and +// b2b.group.member.removed. +type GroupMembersMessage struct { + Timestamp time.Time `json:"timestamp"` + OrganizationID string `json:"organizationId"` + ID string `json:"id"` + Members []GroupMember `json:"members"` +} + +// GroupMember describes a B2B user present in a group membership event. +type GroupMember struct { + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + WorkplaceFQDN string `json:"workplaceFqdn"` +} + +// GroupPatch describes the local fields to create or update on a replicated +// organization-directory group. +type GroupPatch struct { + OrganizationID string + ExternalID string + Name *string + Description *string + Color *string + CreatedAt *time.Time +} + +// GroupDocID returns the stable local document ID used for a replicated B2B +// group. Raw external IDs stay in metadata for traceability. +func GroupDocID(organizationID, externalID string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(organizationID) + "\x00" + strings.TrimSpace(externalID))) + return "b2b-group-" + hex.EncodeToString(sum[:16]) +} + +// SyncGroupCreated replicates a B2B group and its initial members to every +// instance in the organization. +func SyncGroupCreated(ctx context.Context, msg GroupCreatedMessage) error { + if err := validateGroupIdentity("b2b.group.created", msg.OrganizationID, msg.ID); err != nil { + return err + } + name := strings.TrimSpace(msg.Name) + if name == "" { + return fmt.Errorf("b2b.group.created: missing name") + } + createdAt := msg.CreatedAt + fields := GroupPatch{ + OrganizationID: strings.TrimSpace(msg.OrganizationID), + ExternalID: strings.TrimSpace(msg.ID), + Name: &name, + Description: nonEmptyStringPtr(msg.Description), + Color: nonEmptyStringPtr(msg.Color), + CreatedAt: &createdAt, + } + return forEachOrgInstance(ctx, "b2b.group.created", msg.OrganizationID, func(inst *instance.Instance) error { + if err := upsertGroup(inst, fields); err != nil { + return err + } + return addMembersToGroup(ctx, inst, msg.OrganizationID, msg.ID, msg.Members) + }) +} + +// SyncGroupUpdated partially updates a B2B group in every instance in the +// organization. +func SyncGroupUpdated(ctx context.Context, msg GroupUpdatedMessage) error { + if err := validateGroupIdentity("b2b.group.updated", msg.OrganizationID, msg.ID); err != nil { + return err + } + fields := GroupPatch{ + OrganizationID: strings.TrimSpace(msg.OrganizationID), + ExternalID: strings.TrimSpace(msg.ID), + Name: cleanStringPtr(msg.Name), + Description: cleanStringPtr(msg.Description), + Color: cleanStringPtr(msg.Color), + } + return forEachOrgInstance(ctx, "b2b.group.updated", msg.OrganizationID, func(inst *instance.Instance) error { + return upsertGroup(inst, fields) + }) +} + +// SyncGroupDeleted removes a B2B group and first removes its relationship from +// replicated contacts so existing sharing-group triggers see membership deltas. +func SyncGroupDeleted(ctx context.Context, msg GroupDeletedMessage) error { + if err := validateGroupIdentity("b2b.group.deleted", msg.OrganizationID, msg.ID); err != nil { + return err + } + groupID := GroupDocID(msg.OrganizationID, msg.ID) + return forEachOrgInstance(ctx, "b2b.group.deleted", msg.OrganizationID, func(inst *instance.Instance) error { + if err := removeGroupFromManagedContacts(inst, msg.OrganizationID, groupID); err != nil { + return err + } + group, err := contact.FindGroup(inst, groupID) + if couchdb.IsNoDatabaseError(err) || couchdb.IsNotFoundError(err) { + return nil + } + if err != nil { + return err + } + if !IsManagedDirectoryDoc(&group.JSONDoc) { + return fmt.Errorf("b2b.group.deleted: refusing to delete unmanaged group %s on %s", groupID, inst.Domain) + } + return couchdb.DeleteDoc(inst, group) + }) +} + +// SyncGroupMembersAdded adds members to a B2B group in every instance in the +// organization. +func SyncGroupMembersAdded(ctx context.Context, msg GroupMembersMessage) error { + if err := validateMembersMessage("b2b.group.member.added", msg); err != nil { + return err + } + return forEachOrgInstance(ctx, "b2b.group.member.added", msg.OrganizationID, func(inst *instance.Instance) error { + if err := ensureGroupExists(inst, msg.OrganizationID, msg.ID); err != nil { + return err + } + return addMembersToGroup(ctx, inst, msg.OrganizationID, msg.ID, msg.Members) + }) +} + +// SyncGroupMembersRemoved removes a B2B group relationship from member contacts. +func SyncGroupMembersRemoved(ctx context.Context, msg GroupMembersMessage) error { + if err := validateMembersMessage("b2b.group.member.removed", msg); err != nil { + return err + } + groupID := GroupDocID(msg.OrganizationID, msg.ID) + return forEachOrgInstance(ctx, "b2b.group.member.removed", msg.OrganizationID, func(inst *instance.Instance) error { + var errs []error + for _, member := range msg.Members { + if err := ctx.Err(); err != nil { + return err + } + input := contactPatchFromMember(msg.OrganizationID, member) + c, err := findManagedContact(inst, input) + if errors.Is(err, contact.ErrNotFound) { + continue + } + if err != nil { + errs = append(errs, err) + continue + } + changed, err := removeContactGroup(c, groupID) + if err != nil { + errs = append(errs, err) + continue + } + if changed { + if err := couchdb.UpdateDoc(inst, c); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) + }) +} + +// CopyOrgDirectoryFromOrgInstance copies current managed group/contact replicas +// from the organization instance to a newly created member instance. LDAP/B2B +// remains authoritative; the organization instance is only a local copy source +// for late onboarding. +func CopyOrgDirectoryFromOrgInstance(ctx context.Context, target *instance.Instance, organizationID string) error { + organizationID = strings.TrimSpace(organizationID) + if organizationID == "" || target == nil { + return nil + } + orgInst, err := findOrganizationInstance(ctx, organizationID) + if err != nil { + return err + } + if orgInst == nil || orgInst.Domain == target.Domain { + return nil + } + + if err := copyManagedGroups(ctx, orgInst, target, organizationID); err != nil { + return err + } + return copyManagedContactsWithGroups(ctx, orgInst, target, organizationID) +} + +func copyManagedGroups(ctx context.Context, source, target *instance.Instance, organizationID string) error { + groups, err := listManagedGroups(source, organizationID) + if err != nil { + return fmt.Errorf("copy org directory groups from %s: %w", source.Domain, err) + } + for _, g := range groups { + if err := ctx.Err(); err != nil { + return err + } + g.SetRev("") + if err := couchdb.Upsert(target, g); err != nil { + return fmt.Errorf("copy group %s to %s: %w", g.ID(), target.Domain, err) + } + } + return nil +} + +func copyManagedContactsWithGroups(ctx context.Context, source, target *instance.Instance, organizationID string) error { + contacts, err := listManagedContacts(source, organizationID) + if err != nil { + return fmt.Errorf("copy org directory contacts from %s: %w", source.Domain, err) + } + for _, c := range contacts { + if err := ctx.Err(); err != nil { + return err + } + input := contactPatchFromContact(organizationID, c) + if input.shouldSkipOwn(target) { + continue + } + // Contacts are copied with their group refs to restore historical + // memberships that existed before the target instance was created. + stored, err := UpsertManagedContact(target, input) + if err != nil { + return fmt.Errorf("copy contact on %s: %w", target.Domain, err) + } + changed, err := addContactGroups(stored, c.GroupIDs()) + if err != nil { + return fmt.Errorf("copy contact groups on %s: %w", target.Domain, err) + } + if changed { + if err := couchdb.UpdateDoc(target, stored); err != nil { + return fmt.Errorf("copy contact groups %s on %s: %w", stored.ID(), target.Domain, err) + } + } + } + return nil +} + +func forEachOrgInstance(ctx context.Context, eventName, organizationID string, fn func(*instance.Instance) error) error { + scope, err := ResolveOrganizationInstances(organizationID, "") + if err != nil { + return fmt.Errorf("%s: %w", eventName, err) + } + var errs []error + for _, inst := range scope.Instances { + if err := ctx.Err(); err != nil { + return err + } + if err := fn(inst); err != nil { + errs = append(errs, fmt.Errorf("%s on %s: %w", eventName, inst.Domain, err)) + } + } + return errors.Join(errs...) +} + +func upsertGroup(inst *instance.Instance, fields GroupPatch) error { + groupID := GroupDocID(fields.OrganizationID, fields.ExternalID) + doc := contact.NewGroup() + doc.SetID(groupID) + + var existing contact.Group + err := couchdb.GetDoc(inst, consts.Groups, groupID, &existing) + if err == nil { + if !IsManagedDirectoryDoc(&existing.JSONDoc) { + return fmt.Errorf("group id %s already exists and is not managed", groupID) + } + doc.M = existing.M + doc.Type = consts.Groups + doc.SetRev(existing.Rev()) + } else if !couchdb.IsNoDatabaseError(err) && !couchdb.IsNotFoundError(err) { + return err + } + + if fields.Name != nil && *fields.Name != "" { + doc.M["name"] = *fields.Name + } else if doc.M["name"] == nil { + doc.M["name"] = fields.ExternalID + } + applyOptionalStringField(doc.M, "description", fields.Description) + applyOptionalStringField(doc.M, "color", fields.Color) + if fields.CreatedAt != nil && !fields.CreatedAt.IsZero() { + doc.M["createdAt"] = fields.CreatedAt.Format(time.RFC3339Nano) + } + setGroupDirectoryMetadata(&doc.JSONDoc, fields.OrganizationID, fields.ExternalID) + + if doc.Rev() == "" { + return couchdb.CreateNamedDocWithDB(inst, doc) + } + return couchdb.UpdateDoc(inst, doc) +} + +func ensureGroupExists(inst *instance.Instance, organizationID, externalID string) error { + groupID := GroupDocID(organizationID, externalID) + group, err := contact.FindGroup(inst, groupID) + if err != nil { + return err + } + if !IsManagedDirectoryDoc(&group.JSONDoc) { + return fmt.Errorf("group %s exists but is not managed", groupID) + } + return nil +} + +func addMembersToGroup(ctx context.Context, inst *instance.Instance, organizationID, externalGroupID string, members []GroupMember) error { + groupID := GroupDocID(organizationID, externalGroupID) + var errs []error + for _, member := range members { + if err := ctx.Err(); err != nil { + return err + } + input := contactPatchFromMember(organizationID, member) + if input.shouldSkipOwn(inst) { + continue + } + c, err := UpsertManagedContact(inst, input) + if err != nil { + errs = append(errs, err) + continue + } + changed, err := addContactGroup(c, groupID) + if err != nil { + errs = append(errs, err) + continue + } + if changed { + if err := couchdb.UpdateDoc(inst, c); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +// UpsertManagedContact creates or updates a managed organization-directory +// contact, preserving any existing document ID matched by email or Cozy URL. +// Callers should pass values already normalized at their input boundary. +func UpsertManagedContact(inst *instance.Instance, input ContactPatch) (*contact.Contact, error) { + if err := input.validate(); err != nil { + return nil, err + } + c, err := findManagedContact(inst, input) + if errors.Is(err, contact.ErrNotFound) { + c = contact.New() + applyManagedContactFields(c, input) + if err := couchdb.CreateDoc(inst, c); err != nil { + return nil, err + } + return c, nil + } + if err != nil { + return nil, err + } + applyManagedContactFields(c, input) + if err := couchdb.UpdateDoc(inst, c); err != nil { + return nil, err + } + return c, nil +} + +func findManagedContact(inst *instance.Instance, input ContactPatch) (*contact.Contact, error) { + if input.Email != "" { + c, err := findManagedContactByEmail(inst, input.Email) + if err == nil || !errors.Is(err, contact.ErrNotFound) { + return c, err + } + } + if input.CozyURL != "" { + return findManagedContactByCozyURL(inst, input.CozyURL) + } + return nil, contact.ErrNotFound +} + +func applyManagedContactFields(c *contact.Contact, input ContactPatch) { + email := input.Email + if email != "" { + c.M["email"] = []map[string]interface{}{ + {"address": email, "primary": true}, + } + } + name := input.displayName() + if name != "" { + c.M["fullname"] = name + c.M["displayName"] = name + } + if input.CozyURL != "" { + c.M["cozy"] = []map[string]interface{}{ + {"url": input.CozyURL, "primary": true}, + } + } + if input.Phone != "" { + c.M["phone"] = []map[string]interface{}{ + {"number": input.Phone, "primary": true}, + } + } + c.M["metadata"] = map[string]interface{}{"external": true} + c.M[contact.TrustedForSharingKey] = true + setContactDirectoryMetadata(&c.JSONDoc, input, email) + index := email + if input.CozyURL != "" { + index = input.CozyURL + } + if index != "" { + c.M["indexes"] = map[string]interface{}{ + "byFamilyNameGivenNameEmailCozyUrl": index, + } + } +} + +func addContactGroup(c *contact.Contact, groupID string) (bool, error) { + refs := contactGroupRefs(c) + for _, ref := range refs { + if refGroupID(ref) == groupID { + return false, nil + } + } + refs = append(refs, map[string]interface{}{ + "_id": groupID, + "_type": consts.Groups, + }) + setContactGroupRefs(c, refs) + return true, nil +} + +func addContactGroups(c *contact.Contact, groupIDs []string) (bool, error) { + var changed bool + for _, groupID := range groupIDs { + groupID = strings.TrimSpace(groupID) + if groupID == "" { + continue + } + added, err := addContactGroup(c, groupID) + if err != nil { + return false, err + } + changed = changed || added + } + return changed, nil +} + +func removeContactGroup(c *contact.Contact, groupID string) (bool, error) { + refs := contactGroupRefs(c) + if len(refs) == 0 { + return false, nil + } + next := make([]interface{}, 0, len(refs)) + var changed bool + for _, ref := range refs { + if refGroupID(ref) == groupID { + changed = true + continue + } + next = append(next, ref) + } + if changed { + setContactGroupRefs(c, next) + } + return changed, nil +} + +func contactGroupRefs(c *contact.Contact) []interface{} { + rels, _ := c.M["relationships"].(map[string]interface{}) + groups, _ := rels["groups"].(map[string]interface{}) + data, _ := groups["data"].([]interface{}) + return data +} + +func setContactGroupRefs(c *contact.Contact, refs []interface{}) { + rels, _ := c.M["relationships"].(map[string]interface{}) + if rels == nil { + rels = make(map[string]interface{}) + } + groups, _ := rels["groups"].(map[string]interface{}) + if groups == nil { + groups = make(map[string]interface{}) + } + groups["data"] = refs + rels["groups"] = groups + c.M["relationships"] = rels +} + +func refGroupID(ref interface{}) string { + m, _ := ref.(map[string]interface{}) + if m == nil || m["_type"] != consts.Groups { + return "" + } + id, _ := m["_id"].(string) + return id +} + +func removeGroupFromManagedContacts(inst *instance.Instance, organizationID, groupID string) error { + docs, err := listManagedContacts(inst, organizationID) + if err != nil { + return err + } + var errs []error + for _, c := range docs { + changed, err := removeContactGroup(c, groupID) + if err != nil { + errs = append(errs, err) + continue + } + if changed { + if err := couchdb.UpdateDoc(inst, c); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +func validateGroupIdentity(eventName, organizationID, groupID string) error { + if strings.TrimSpace(organizationID) == "" { + return fmt.Errorf("%s: missing organizationId", eventName) + } + if strings.TrimSpace(groupID) == "" { + return fmt.Errorf("%s: missing id", eventName) + } + return nil +} + +func validateMembersMessage(eventName string, msg GroupMembersMessage) error { + if err := validateGroupIdentity(eventName, msg.OrganizationID, msg.ID); err != nil { + return err + } + if len(msg.Members) == 0 { + return fmt.Errorf("%s: missing members", eventName) + } + return nil +} + +func applyOptionalStringField(m map[string]interface{}, key string, value *string) { + if value == nil { + return + } + if *value == "" { + delete(m, key) + return + } + m[key] = *value +} + +func nonEmptyStringPtr(v string) *string { + cleaned := strings.TrimSpace(v) + if cleaned == "" { + return nil + } + return &cleaned +} + +func cleanStringPtr(v *string) *string { + if v == nil { + return nil + } + cleaned := strings.TrimSpace(*v) + return &cleaned +} diff --git a/model/orgdirectory/groups_test.go b/model/orgdirectory/groups_test.go new file mode 100644 index 00000000000..dc6f1a69dbd --- /dev/null +++ b/model/orgdirectory/groups_test.go @@ -0,0 +1,305 @@ +package orgdirectory + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/cozy/cozy-stack/model/contact" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/stretchr/testify/require" +) + +func TestGroupDocID(t *testing.T) { + id1 := GroupDocID("org123", "engineering") + id2 := GroupDocID("org123", "engineering") + id3 := GroupDocID("org456", "engineering") + + require.Equal(t, id1, id2) + require.NotEqual(t, id1, id3) + require.Contains(t, id1, "b2b-group-") +} + +func TestSyncGroupCreatedAdoptsExistingExternalContact(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + orgID := "org-groups-" + suffix + orgDomain := "groups-" + suffix + ".example" + alice := createOrgDirectoryInstance(t, "alice-groups-"+suffix+".local", orgDomain, orgID, "alice@acme.test", "Alice") + bob := createOrgDirectoryInstance(t, "bob-groups-"+suffix+".local", orgDomain, orgID, "bob@acme.test", "Bob") + + existing := createExternalContact(t, bob, "alice@acme.test", "Old Alice") + + err := SyncGroupCreated(testCtx(t), GroupCreatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Name: "Engineering", + Description: "Engineering team", + Color: "#3366FF", + Members: []GroupMember{ + { + Username: "alice", + Email: "alice@acme.test", + FirstName: "Alice", + WorkplaceFQDN: alice.Domain, + }, + }, + }) + require.NoError(t, err) + + groupID := GroupDocID(orgID, "engineering") + group, err := contact.FindGroup(bob, groupID) + require.NoError(t, err) + require.Equal(t, "Engineering", group.Name()) + require.True(t, IsManagedDirectoryDoc(&group.JSONDoc)) + + stored, err := contact.Find(bob, existing.ID()) + require.NoError(t, err) + require.Equal(t, existing.ID(), stored.ID()) + require.True(t, stored.IsExternal()) + require.True(t, stored.IsTrusted()) + require.True(t, IsManagedDirectoryDoc(&stored.JSONDoc)) + require.Equal(t, alice.PageURL("", nil), stored.PrimaryCozyURL()) + require.Contains(t, stored.GroupIDs(), groupID) +} + +func TestSyncGroupDeletedRemovesRelationshipsBeforeDeletingGroup(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + orgID := "org-delete-group-" + suffix + orgDomain := "delete-group-" + suffix + ".example" + alice := createOrgDirectoryInstance(t, "alice-delete-group-"+suffix+".local", orgDomain, orgID, "alice@acme.test", "Alice") + bob := createOrgDirectoryInstance(t, "bob-delete-group-"+suffix+".local", orgDomain, orgID, "bob@acme.test", "Bob") + + err := SyncGroupCreated(testCtx(t), GroupCreatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Name: "Engineering", + Members: []GroupMember{ + { + Username: "alice", + Email: "alice@acme.test", + FirstName: "Alice", + WorkplaceFQDN: alice.Domain, + }, + }, + }) + require.NoError(t, err) + + groupID := GroupDocID(orgID, "engineering") + bobContacts, err := contact.FindAllByEmail(bob, "alice@acme.test") + require.NoError(t, err) + require.Len(t, bobContacts, 1) + require.Contains(t, bobContacts[0].GroupIDs(), groupID) + + err = SyncGroupDeleted(testCtx(t), GroupDeletedMessage{ + OrganizationID: orgID, + ID: "engineering", + }) + require.NoError(t, err) + + _, err = contact.FindGroup(bob, groupID) + require.True(t, couchdb.IsNotFoundError(err)) + + stored, err := contact.Find(bob, bobContacts[0].ID()) + require.NoError(t, err) + require.NotContains(t, stored.GroupIDs(), groupID) +} + +func TestSyncGroupOptionalFieldsOmitPreserveAndClear(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + orgID := "org-optional-group-" + suffix + orgDomain := "optional-group-" + suffix + ".example" + inst := createOrgDirectoryInstance(t, "optional-group-"+suffix+".local", orgDomain, orgID, "owner@acme.test", "Owner") + + err := SyncGroupCreated(testCtx(t), GroupCreatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Name: "Engineering", + }) + require.NoError(t, err) + + groupID := GroupDocID(orgID, "engineering") + group, err := contact.FindGroup(inst, groupID) + require.NoError(t, err) + _, hasDescription := group.M["description"] + _, hasColor := group.M["color"] + require.False(t, hasDescription) + require.False(t, hasColor) + + description := "Platform engineering" + color := "#22AA55" + err = SyncGroupUpdated(testCtx(t), GroupUpdatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Description: &description, + Color: &color, + }) + require.NoError(t, err) + + group, err = contact.FindGroup(inst, groupID) + require.NoError(t, err) + require.Equal(t, "Platform engineering", group.M["description"]) + require.Equal(t, "#22AA55", group.M["color"]) + + err = SyncGroupUpdated(testCtx(t), GroupUpdatedMessage{ + OrganizationID: orgID, + ID: "engineering", + }) + require.NoError(t, err) + + group, err = contact.FindGroup(inst, groupID) + require.NoError(t, err) + require.Equal(t, "Platform engineering", group.M["description"]) + require.Equal(t, "#22AA55", group.M["color"]) + + empty := "" + err = SyncGroupUpdated(testCtx(t), GroupUpdatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Description: &empty, + }) + require.NoError(t, err) + + group, err = contact.FindGroup(inst, groupID) + require.NoError(t, err) + _, hasDescription = group.M["description"] + require.False(t, hasDescription) + require.Equal(t, "#22AA55", group.M["color"]) + + err = SyncGroupUpdated(testCtx(t), GroupUpdatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Color: &empty, + }) + require.NoError(t, err) + + group, err = contact.FindGroup(inst, groupID) + require.NoError(t, err) + _, hasColor = group.M["color"] + require.False(t, hasColor) +} + +func TestCopyOrgDirectoryFromOrgInstanceUsesGeneratedContactIDAndReusesIt(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + orgID := "orgcopy" + suffix + orgDomain := "copy-" + suffix + ".example" + orgInst := createOrgDirectoryInstance(t, orgID+".local", orgDomain, orgID, "owner@acme.test", "Owner") + + err := SyncGroupCreated(testCtx(t), GroupCreatedMessage{ + OrganizationID: orgID, + ID: "engineering", + Name: "Engineering", + Members: []GroupMember{ + { + Username: "alice", + Email: "alice@acme.test", + FirstName: "Alice", + WorkplaceFQDN: "alice-" + suffix + ".local", + }, + }, + }) + require.NoError(t, err) + + sourceContacts, err := contact.FindAllByEmail(orgInst, "alice@acme.test") + require.NoError(t, err) + require.Len(t, sourceContacts, 1) + require.NotContains(t, sourceContacts[0].ID(), "b2b-contact-", "source contact keeps generated id") + + member := createOrgDirectoryInstance(t, "bob-copy-"+suffix+".local", orgDomain, orgID, "bob@acme.test", "Bob") + require.NoError(t, CopyOrgDirectoryFromOrgInstance(testCtx(t), member, orgID)) + require.NoError(t, CopyOrgDirectoryFromOrgInstance(testCtx(t), member, orgID)) + + targetContacts, err := contact.FindAllByEmail(member, "alice@acme.test") + require.NoError(t, err) + require.Len(t, targetContacts, 1) + require.NotContains(t, targetContacts[0].ID(), "b2b-contact-") + require.True(t, IsManagedDirectoryDoc(&targetContacts[0].JSONDoc)) + require.Contains(t, targetContacts[0].GroupIDs(), GroupDocID(orgID, "engineering")) +} + +func TestUpsertManagedContactRequiresOnlyEmail(t *testing.T) { + config.UseTestFile(t) + needCouchDB(t) + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + orgID := "org-contact-patch-" + suffix + orgDomain := "contact-patch-" + suffix + ".example" + inst := createOrgDirectoryInstance(t, "contact-patch-"+suffix+".local", orgDomain, orgID, "owner@acme.test", "Owner") + + stored, err := UpsertManagedContact(inst, ContactPatch{ + OrganizationID: orgID, + Email: "alice@acme.test", + Name: "Alice", + }) + require.NoError(t, err) + require.Equal(t, "Alice", stored.PrimaryName()) + + matches, err := contact.FindAllByEmail(inst, "alice@acme.test") + require.NoError(t, err) + require.Len(t, matches, 1) + require.Equal(t, stored.ID(), matches[0].ID()) + + _, err = UpsertManagedContact(inst, ContactPatch{ + OrganizationID: orgID, + WorkplaceFQDN: "alice-" + suffix + ".local", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "contact missing email") +} + +func createOrgDirectoryInstance(t *testing.T, domain, orgDomain, orgID, email, publicName string) *instance.Instance { + t.Helper() + inst, err := lifecycle.Create(&lifecycle.Options{ + Domain: domain, + OrgDomain: orgDomain, + OrgID: orgID, + Email: email, + PublicName: publicName, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lifecycle.Destroy(inst.Domain) }) + return inst +} + +func createExternalContact(t *testing.T, inst *instance.Instance, email, name string) *contact.Contact { + t.Helper() + c, err := contact.Create(inst, contact.CreateOptions{ + Email: email, + Name: name, + External: true, + TrustedForSharing: true, + }) + require.NoError(t, err) + return c +} + +func testCtx(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + return ctx +} + +func needCouchDB(t *testing.T) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := couchdb.CheckStatus(ctx); err != nil { + t.Skipf("couchdb is required for this test: %v", err) + } +} diff --git a/model/orgdirectory/metadata.go b/model/orgdirectory/metadata.go new file mode 100644 index 00000000000..65f95f055d3 --- /dev/null +++ b/model/orgdirectory/metadata.go @@ -0,0 +1,83 @@ +package orgdirectory + +import ( + "github.com/go-viper/mapstructure/v2" + + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" +) + +const ( + DirectoryMetadataKey = "twakeDirectory" + metadataKindGroup = "group" + metadataKindContact = "contact" +) + +// DirectoryMetadata describes the B2B organization directory ownership stored +// on managed contact and group documents. +type DirectoryMetadata struct { + Managed bool `json:"managed" mapstructure:"managed"` + Kind string `json:"kind,omitempty" mapstructure:"kind"` + OrganizationID string `json:"organizationId,omitempty" mapstructure:"organizationId"` + ExternalID string `json:"externalId,omitempty" mapstructure:"externalId"` + Username string `json:"username,omitempty" mapstructure:"username"` + Email string `json:"email,omitempty" mapstructure:"email"` + WorkplaceFQDN string `json:"workplaceFqdn,omitempty" mapstructure:"workplaceFqdn"` +} + +// IsManagedDirectoryDoctype reports whether a doctype can contain managed +// organization directory documents. +func IsManagedDirectoryDoctype(doctype string) bool { + return doctype == consts.Contacts || doctype == consts.Groups +} + +// IsManagedDirectoryDoc reports whether a contact or group document is managed +// by the B2B organization directory replication. +func IsManagedDirectoryDoc(doc *couchdb.JSONDoc) bool { + meta := directoryMetadata(doc) + return meta.Managed +} + +func directoryMetadata(doc *couchdb.JSONDoc) DirectoryMetadata { + if doc == nil || doc.M == nil { + return DirectoryMetadata{} + } + return decodeDirectoryMetadata(doc.M[DirectoryMetadataKey]) +} + +func decodeDirectoryMetadata(raw interface{}) DirectoryMetadata { + if raw == nil { + return DirectoryMetadata{} + } + var meta DirectoryMetadata + if err := mapstructure.Decode(raw, &meta); err != nil { + return DirectoryMetadata{} + } + return meta +} + +func setGroupDirectoryMetadata(doc *couchdb.JSONDoc, organizationID, externalID string) { + if doc.M == nil { + doc.M = make(map[string]interface{}) + } + doc.M[DirectoryMetadataKey] = DirectoryMetadata{ + Managed: true, + Kind: metadataKindGroup, + OrganizationID: organizationID, + ExternalID: externalID, + } +} + +func setContactDirectoryMetadata(doc *couchdb.JSONDoc, input ContactPatch, email string) { + if doc.M == nil { + doc.M = make(map[string]interface{}) + } + doc.M[DirectoryMetadataKey] = DirectoryMetadata{ + Managed: true, + Kind: metadataKindContact, + OrganizationID: input.OrganizationID, + Username: input.Username, + Email: email, + WorkplaceFQDN: input.WorkplaceFQDN, + } +} diff --git a/model/orgdirectory/organizations.go b/model/orgdirectory/organizations.go new file mode 100644 index 00000000000..9f521c8c588 --- /dev/null +++ b/model/orgdirectory/organizations.go @@ -0,0 +1,76 @@ +package orgdirectory + +import ( + "context" + "fmt" + "strings" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/utils" +) + +// OrganizationInstances is the resolved local instance scope for an +// organization-directory operation. +type OrganizationInstances struct { + OrganizationID string + Instances []*instance.Instance +} + +// ResolveOrganizationInstances retrieves organization instances by ID when it +// is available, otherwise by organization domain. +func ResolveOrganizationInstances(organizationID, organizationDomain string) (OrganizationInstances, error) { + organizationID = strings.TrimSpace(organizationID) + organizationDomain = utils.NormalizeDomain(organizationDomain) + + var list []*instance.Instance + var err error + if organizationID != "" { + list, err = lifecycle.ListOrgInstancesByID(organizationID) + if err != nil { + return OrganizationInstances{}, fmt.Errorf("list organization instances by id %s: %w", organizationID, err) + } + } else if organizationDomain != "" { + list, err = lifecycle.ListOrgInstances(organizationDomain) + if err != nil { + return OrganizationInstances{}, fmt.Errorf("list organization instances by domain %s: %w", organizationDomain, err) + } + } else { + return OrganizationInstances{}, fmt.Errorf("missing organizationId or organization domain") + } + + if len(list) == 0 { + return OrganizationInstances{}, fmt.Errorf("organization has no instances") + } + + resolvedID := organizationID + if resolvedID == "" { + for _, inst := range list { + resolvedID = strings.TrimSpace(inst.OrgID) + if resolvedID != "" { + break + } + } + } + + return OrganizationInstances{ + OrganizationID: resolvedID, + Instances: list, + }, nil +} + +func findOrganizationInstance(ctx context.Context, organizationID string) (*instance.Instance, error) { + scope, err := ResolveOrganizationInstances(organizationID, "") + if err != nil { + return nil, fmt.Errorf("org-directory: %w", err) + } + for _, inst := range scope.Instances { + if err := ctx.Err(); err != nil { + return nil, err + } + if inst.IsOrganizationInstance() { + return inst, nil + } + } + return nil, nil +} diff --git a/model/orgdirectory/store.go b/model/orgdirectory/store.go new file mode 100644 index 00000000000..3c3f06a4c35 --- /dev/null +++ b/model/orgdirectory/store.go @@ -0,0 +1,120 @@ +package orgdirectory + +import ( + "errors" + "fmt" + + "github.com/cozy/cozy-stack/model/contact" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/couchdb/mango" + "github.com/cozy/cozy-stack/pkg/prefixer" +) + +const managedDocsPageSize = 1000 + +func findManagedContactByEmail(db prefixer.Prefixer, email string) (*contact.Contact, error) { + matches, err := contact.FindAllByEmail(db, email) + if errors.Is(err, contact.ErrNotFound) { + return nil, contact.ErrNotFound + } + if err != nil { + return nil, err + } + return singleManagedContact(matches, "email "+email) +} + +func findManagedContactByCozyURL(db prefixer.Prefixer, cozyURL string) (*contact.Contact, error) { + var docs []*contact.Contact + req := &couchdb.FindRequest{ + Selector: mango.Map{ + "cozy": map[string]interface{}{ + "$elemMatch": map[string]interface{}{ + "url": cozyURL, + }, + }, + }, + Limit: 2, + } + err := couchdb.FindDocsUnoptimized(db, consts.Contacts, req, &docs) + if couchdb.IsNoDatabaseError(err) || couchdb.IsNotFoundError(err) { + return nil, contact.ErrNotFound + } + if err != nil { + return nil, err + } + if len(docs) == 0 { + return nil, contact.ErrNotFound + } + return singleManagedContact(docs, "cozy URL "+cozyURL) +} + +func singleManagedContact(matches []*contact.Contact, label string) (*contact.Contact, error) { + var managed []*contact.Contact + for _, doc := range matches { + if doc.IsExternal() || IsManagedDirectoryDoc(&doc.JSONDoc) { + managed = append(managed, doc) + } + } + if len(managed) == 0 { + return nil, contact.ErrNotFound + } + if len(managed) > 1 { + return nil, fmt.Errorf("multiple managed contacts found for %s", label) + } + return managed[0], nil +} + +func listManagedGroups(inst *instance.Instance, organizationID string) ([]*contact.Group, error) { + docs, err := listManagedDocs[contact.Group](inst, consts.Groups, organizationID) + for _, doc := range docs { + doc.Type = consts.Groups + } + return docs, err +} + +func listManagedContacts(inst *instance.Instance, organizationID string) ([]*contact.Contact, error) { + docs, err := listManagedDocs[contact.Contact](inst, consts.Contacts, organizationID) + for _, doc := range docs { + doc.Type = consts.Contacts + } + return docs, err +} + +func listManagedDocs[T any](inst *instance.Instance, doctype, organizationID string) ([]*T, error) { + var docs []*T + var bookmark string + for { + var page []*T + req := managedDocsRequest(organizationID, bookmark) + res, err := couchdb.FindDocsUnoptimizedRaw(inst, doctype, req, &page) + if couchdb.IsNoDatabaseError(err) || couchdb.IsNotFoundError(err) { + return nil, nil + } + if err != nil { + return nil, err + } + docs = append(docs, page...) + + nextBookmark := "" + if res != nil { + nextBookmark = res.Bookmark + } + if len(page) < managedDocsPageSize || nextBookmark == "" || nextBookmark == bookmark { + return docs, nil + } + bookmark = nextBookmark + } +} + +func managedDocsRequest(organizationID, bookmark string) *couchdb.FindRequest { + return &couchdb.FindRequest{ + Selector: mango.And( + mango.Equal(DirectoryMetadataKey+".managed", true), + mango.Equal(DirectoryMetadataKey+".organizationId", organizationID), + ), + Limit: managedDocsPageSize, + Bookmark: bookmark, + } +} diff --git a/pkg/couchdb/couchdb.go b/pkg/couchdb/couchdb.go index 7011b06a374..e60431c9dc9 100644 --- a/pkg/couchdb/couchdb.go +++ b/pkg/couchdb/couchdb.go @@ -776,6 +776,13 @@ func FindDocsUnoptimized(db prefixer.Prefixer, doctype string, req *FindRequest, return err } +// FindDocsUnoptimizedRaw allows search on non-indexed fields and returns the +// raw find response, including the bookmark used for pagination. +// /!\ Use with care +func FindDocsUnoptimizedRaw(db prefixer.Prefixer, doctype string, req *FindRequest, results interface{}) (*FindResponse, error) { + return findDocsRaw(db, doctype, req, results, true) +} + func findDocsRaw(db prefixer.Prefixer, doctype string, req interface{}, results interface{}, ignoreUnoptimized bool) (*FindResponse, error) { url := "_find" // prepare a structure to receive the results diff --git a/pkg/couchdb/index.go b/pkg/couchdb/index.go index 9a1cfd292bf..337820bccff 100644 --- a/pkg/couchdb/index.go +++ b/pkg/couchdb/index.go @@ -14,7 +14,7 @@ import ( // IndexViewsVersion is the version of current definition of views & indexes. // This number should be incremented when this file changes. -const IndexViewsVersion int = 38 +const IndexViewsVersion int = 39 // Indexes is the index list required by an instance to run properly. var Indexes = []*mango.Index{ @@ -324,6 +324,7 @@ var globalIndexes = []*mango.Index{ mango.MakeIndex(consts.Instances, "by-oidcid", mango.IndexDef{Fields: []string{"oidc_id"}}), mango.MakeIndex(consts.Instances, "by-olddomain", mango.IndexDef{Fields: []string{"old_domain"}}), mango.MakeIndex(consts.Instances, "by-orgdomain", mango.IndexDef{Fields: []string{"org_domain"}}), + mango.MakeIndex(consts.Instances, "by-orgid", mango.IndexDef{Fields: []string{"org_id"}}), } // secretIndexes is the index list required on the secret databases to run diff --git a/pkg/rabbitmq/contracts.go b/pkg/rabbitmq/contracts.go index b06ce9f2388..19a0f5e25b2 100644 --- a/pkg/rabbitmq/contracts.go +++ b/pkg/rabbitmq/contracts.go @@ -2,6 +2,7 @@ package rabbitmq const ( ExchangeAuth = "auth" + ExchangeB2B = "b2b" ExchangeMigration = "migration" ) @@ -13,12 +14,18 @@ const ( QueueUser2FAUpdated = "stack.user.2fa.updated" QueueUserRecoveryEmailUpdated = "stack.user.recovery-email.updated" QueueB2BUserDeleted = "stack.b2b.user.deleted" + QueueB2BGroupLifecycle = "stack.b2b.group.lifecycle" QueueAppCommands = "stack.app.commands.queue" ) const ( RoutingKeyUserPasswordUpdated = "user.password.updated" RoutingKeyB2BUserDeleted = "domain.user.deleted" + RoutingKeyB2BGroupCreated = "b2b.group.created" + RoutingKeyB2BGroupUpdated = "b2b.group.updated" + RoutingKeyB2BGroupDeleted = "b2b.group.deleted" + RoutingKeyB2BGroupMemberAdded = "b2b.group.member.added" + RoutingKeyB2BGroupMemberRemoved = "b2b.group.member.removed" RoutingKeyUserDeletionRequested = "user.deletion.requested" RoutingKeyNextcloudMigrationRequested = "nextcloud.migration.requested" RoutingKeyNextcloudMigrationCanceled = "nextcloud.migration.canceled" diff --git a/pkg/rabbitmq/handlers.go b/pkg/rabbitmq/handlers.go index 4b4afa10e93..7ea84525095 100644 --- a/pkg/rabbitmq/handlers.go +++ b/pkg/rabbitmq/handlers.go @@ -13,6 +13,7 @@ import ( "github.com/cozy/cozy-stack/model/app" "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/model/orgdirectory" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/crypto" ) @@ -223,11 +224,23 @@ func (h *UserCreatedHandler) Handle(ctx context.Context, d amqp.Delivery) error log.Infof("user.created: successfully updated passphrase for instance: %s (PasswordDefined: %v)", inst.Domain, inst.PasswordDefined) } - if msg.OrganizationDomain != "" { - if err := SyncCreatedOrgContact(ctx, inst, msg); err != nil { + if strings.TrimSpace(msg.OrganizationID) != "" || strings.TrimSpace(msg.OrganizationDomain) != "" { + scope, err := orgdirectory.ResolveOrganizationInstances(msg.OrganizationID, msg.OrganizationDomain) + if err != nil { + err = fmt.Errorf("user.created: %w", err) + log.Errorf("user.created: failed to resolve organization instances for %s: %v", msg.WorkplaceFqdn, err) + return err + } + if err := syncCreatedOrgContact(ctx, inst, msg, scope); err != nil { log.Errorf("user.created: failed to sync organization contacts for %s: %v", msg.WorkplaceFqdn, err) return err } + if scope.OrganizationID != "" { + if err := orgdirectory.CopyOrgDirectoryFromOrgInstance(ctx, inst, scope.OrganizationID); err != nil { + log.Errorf("user.created: failed to copy organization directory for %s: %v", msg.WorkplaceFqdn, err) + return err + } + } } return nil @@ -274,9 +287,6 @@ func (h *UserDeletedHandler) Handle(ctx context.Context, d amqp.Delivery) error if msg.InternalEmail == "" { return fmt.Errorf("user.deleted: missing internalEmail") } - if msg.Domain == "" { - return fmt.Errorf("user.deleted: missing organization domain") - } log.Infof("user.deleted: processing message - UserID: %s, InternalEmail: %s, WorkplaceFqdn: %s, Domain: %s, OrganizationID: %s, Reason: %s", msg.UserID, msg.InternalEmail, msg.WorkplaceFqdn, msg.Domain, msg.OrganizationID, msg.Reason) @@ -289,6 +299,56 @@ func (h *UserDeletedHandler) Handle(ctx context.Context, d amqp.Delivery) error return nil } +// B2BGroupLifecycleHandler handles B2B group lifecycle and membership events. +type B2BGroupLifecycleHandler struct{} + +// NewB2BGroupLifecycleHandler creates a new B2B group lifecycle handler. +func NewB2BGroupLifecycleHandler() *B2BGroupLifecycleHandler { + return &B2BGroupLifecycleHandler{} +} + +// Handle processes B2B group lifecycle messages. +func (h *B2BGroupLifecycleHandler) Handle(ctx context.Context, d amqp.Delivery) error { + log.Infof("b2b.group: received message: %s", d.RoutingKey) + log.Debugf("b2b.group: message details - MessageId: %s, ContentType: %s, Body size: %d bytes", + d.MessageId, d.ContentType, len(d.Body)) + + switch d.RoutingKey { + case RoutingKeyB2BGroupCreated: + var msg orgdirectory.GroupCreatedMessage + if err := json.Unmarshal(d.Body, &msg); err != nil { + return fmt.Errorf("b2b.group.created: failed to unmarshal message: %w", err) + } + return orgdirectory.SyncGroupCreated(ctx, msg) + case RoutingKeyB2BGroupUpdated: + var msg orgdirectory.GroupUpdatedMessage + if err := json.Unmarshal(d.Body, &msg); err != nil { + return fmt.Errorf("b2b.group.updated: failed to unmarshal message: %w", err) + } + return orgdirectory.SyncGroupUpdated(ctx, msg) + case RoutingKeyB2BGroupDeleted: + var msg orgdirectory.GroupDeletedMessage + if err := json.Unmarshal(d.Body, &msg); err != nil { + return fmt.Errorf("b2b.group.deleted: failed to unmarshal message: %w", err) + } + return orgdirectory.SyncGroupDeleted(ctx, msg) + case RoutingKeyB2BGroupMemberAdded: + var msg orgdirectory.GroupMembersMessage + if err := json.Unmarshal(d.Body, &msg); err != nil { + return fmt.Errorf("b2b.group.member.added: failed to unmarshal message: %w", err) + } + return orgdirectory.SyncGroupMembersAdded(ctx, msg) + case RoutingKeyB2BGroupMemberRemoved: + var msg orgdirectory.GroupMembersMessage + if err := json.Unmarshal(d.Body, &msg); err != nil { + return fmt.Errorf("b2b.group.member.removed: failed to unmarshal message: %w", err) + } + return orgdirectory.SyncGroupMembersRemoved(ctx, msg) + default: + return fmt.Errorf("b2b.group: unsupported routing key %s", d.RoutingKey) + } +} + // UserPhoneUpdatedHandler handles user phone update messages. type UserPhoneUpdatedHandler struct{} diff --git a/pkg/rabbitmq/org_contacts.go b/pkg/rabbitmq/org_contacts.go index 594bfc5c17a..3d9f3c8eb97 100644 --- a/pkg/rabbitmq/org_contacts.go +++ b/pkg/rabbitmq/org_contacts.go @@ -8,18 +8,29 @@ import ( "github.com/cozy/cozy-stack/model/contact" "github.com/cozy/cozy-stack/model/instance" - "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/model/orgdirectory" "github.com/cozy/cozy-stack/pkg/couchdb" - "github.com/cozy/cozy-stack/pkg/utils" ) // SyncCreatedOrgContact syncs a newly created B2B user as an external contact // into every other instance of the organization. func SyncCreatedOrgContact(ctx context.Context, target *instance.Instance, msg UserCreatedMessage) error { + scope, err := orgdirectory.ResolveOrganizationInstances(msg.OrganizationID, msg.OrganizationDomain) + if err != nil { + return fmt.Errorf("user.created: %w", err) + } + return syncCreatedOrgContact(ctx, target, msg, scope) +} + +func syncCreatedOrgContact(ctx context.Context, target *instance.Instance, msg UserCreatedMessage, scope orgdirectory.OrganizationInstances) error { email := strings.TrimSpace(msg.InternalEmail) if email == "" { return fmt.Errorf("user.created: missing internalEmail for organization contact sync") } + if scope.OrganizationID == "" { + return fmt.Errorf("user.created: missing organizationId") + } + name, err := target.SettingsPublicName() if err != nil { return fmt.Errorf("user.created: resolve contact name for %s: %w", target.Domain, err) @@ -29,52 +40,36 @@ func SyncCreatedOrgContact(ctx context.Context, target *instance.Instance, msg U return fmt.Errorf("user.created: missing public_name in settings for %s", target.Domain) } targetURL := target.PageURL("", nil) - - instances, err := listOrgContactInstances("user.created", msg.OrganizationDomain) - if err != nil { - return err - } + workplaceFQDN := strings.TrimSpace(msg.WorkplaceFqdn) var lastErr error - for _, inst := range instances { + for _, inst := range scope.Instances { if err := ctx.Err(); err != nil { return err } - if inst.HasDomain(msg.WorkplaceFqdn) { + if inst.HasDomain(workplaceFQDN) { log.Debugf("user.created: creating contacts for own instance %s for organization contact %s", inst.Domain, targetURL) - if err := syncExistingOrgContactsToCreatedUser(ctx, target, instances, msg.WorkplaceFqdn); err != nil { + if err := syncExistingOrgContactsToCreatedUser(ctx, target, scope.Instances, workplaceFQDN, scope.OrganizationID); err != nil { log.Errorf("%v", err) lastErr = err } continue } - existing, err := findExternalOrgContactByEmail(inst, email) - if err != nil { - wrappedErr := fmt.Errorf("user.created: find external contact for %s in %s: %w", email, inst.Domain, err) - log.Errorf("%v", wrappedErr) - lastErr = wrappedErr - continue - } - if existing != nil { - log.Infof("user.created: external contact for %s already exists in %s, skipping", email, inst.Domain) - continue - } - - if _, err := contact.Create(inst, contact.CreateOptions{ - Email: email, - Name: name, - CozyURL: targetURL, - Phone: msg.Mobile, - External: true, - TrustedForSharing: true, + if _, err := orgdirectory.UpsertManagedContact(inst, orgdirectory.ContactPatch{ + OrganizationID: scope.OrganizationID, + Email: email, + Name: name, + CozyURL: targetURL, + Phone: strings.TrimSpace(msg.Mobile), + WorkplaceFQDN: workplaceFQDN, }); err != nil { - wrappedErr := fmt.Errorf("user.created: create contact for %s in %s: %w", email, inst.Domain, err) + wrappedErr := fmt.Errorf("user.created: sync contact for %s in %s: %w", email, inst.Domain, err) log.Errorf("%v", wrappedErr) lastErr = wrappedErr continue } - log.Infof("user.created: created organization contact for %s in %s", email, inst.Domain) + log.Infof("user.created: synced organization contact for %s in %s", email, inst.Domain) } return lastErr @@ -91,13 +86,13 @@ func SyncDeletedOrgContact(ctx context.Context, msg UserDeletedMessage) error { return fmt.Errorf("user.deleted: missing internalEmail") } - instances, err := listOrgContactInstances("user.deleted", msg.Domain) + scope, err := orgdirectory.ResolveOrganizationInstances(msg.OrganizationID, msg.Domain) if err != nil { - return err + return fmt.Errorf("user.deleted: %w", err) } var lastErr error - for _, inst := range instances { + for _, inst := range scope.Instances { if err := ctx.Err(); err != nil { return err } @@ -129,24 +124,7 @@ func SyncDeletedOrgContact(ctx context.Context, msg UserDeletedMessage) error { return lastErr } -func listOrgContactInstances(eventName, orgDomain string) ([]*instance.Instance, error) { - orgDomain = utils.NormalizeDomain(orgDomain) - if orgDomain == "" { - return nil, fmt.Errorf("%s: missing organization domain", eventName) - } - - list, err := lifecycle.ListOrgInstances(orgDomain) - if err != nil { - return nil, fmt.Errorf("%s: list organization instances by domain %s: %w", eventName, orgDomain, err) - } - if len(list) == 0 { - log.Infof("%s: no instances found for organization domain %s", eventName, orgDomain) - return nil, fmt.Errorf("%s: organization has no instances", eventName) - } - return list, nil -} - -func syncExistingOrgContactsToCreatedUser(ctx context.Context, target *instance.Instance, instances []*instance.Instance, workplaceFqdn string) error { +func syncExistingOrgContactsToCreatedUser(ctx context.Context, target *instance.Instance, instances []*instance.Instance, workplaceFqdn, organizationID string) error { var lastErr error for _, inst := range instances { if err := ctx.Err(); err != nil { @@ -156,7 +134,7 @@ func syncExistingOrgContactsToCreatedUser(ctx context.Context, target *instance. continue } - opts, err := externalOrgContactFromInstance(inst) + input, err := externalOrgContactFromInstance(inst, organizationID) if err != nil { wrappedErr := fmt.Errorf("user.created: build existing user contact for %s: %w", inst.Domain, err) log.Errorf("%v", wrappedErr) @@ -164,56 +142,44 @@ func syncExistingOrgContactsToCreatedUser(ctx context.Context, target *instance. continue } - existing, err := findExternalOrgContactByEmail(target, opts.Email) - if err != nil { - wrappedErr := fmt.Errorf("user.created: find external contact for %s in %s: %w", opts.Email, target.Domain, err) - log.Errorf("%v", wrappedErr) - lastErr = wrappedErr - continue - } - if existing != nil { - log.Infof("user.created: external contact for %s already exists in %s, skipping", opts.Email, target.Domain) - continue - } - - if _, err := contact.Create(target, opts); err != nil { - wrappedErr := fmt.Errorf("user.created: create contact for %s in %s: %w", opts.Email, target.Domain, err) + if _, err := orgdirectory.UpsertManagedContact(target, input); err != nil { + wrappedErr := fmt.Errorf("user.created: sync contact for %s in %s: %w", input.Email, target.Domain, err) log.Errorf("%v", wrappedErr) lastErr = wrappedErr continue } - log.Infof("user.created: created organization contact for %s in %s", opts.Email, target.Domain) + log.Infof("user.created: synced organization contact for %s in %s", input.Email, target.Domain) } return lastErr } -func externalOrgContactFromInstance(inst *instance.Instance) (contact.CreateOptions, error) { +func externalOrgContactFromInstance(inst *instance.Instance, organizationID string) (orgdirectory.ContactPatch, error) { settings, err := inst.SettingsDocument() if err != nil { - return contact.CreateOptions{}, err + return orgdirectory.ContactPatch{}, err } email, _ := settings.M["email"].(string) email = strings.TrimSpace(email) if email == "" { - return contact.CreateOptions{}, fmt.Errorf("missing email in settings") + return orgdirectory.ContactPatch{}, fmt.Errorf("missing email in settings") } name, _ := settings.M["public_name"].(string) name = strings.TrimSpace(name) if name == "" { - return contact.CreateOptions{}, fmt.Errorf("missing public_name in settings") + return orgdirectory.ContactPatch{}, fmt.Errorf("missing public_name in settings") } phone, _ := settings.M["phone"].(string) - return contact.CreateOptions{ - Email: email, - Name: name, - CozyURL: inst.PageURL("", nil), - Phone: phone, - External: true, - TrustedForSharing: true, + return orgdirectory.ContactPatch{ + OrganizationID: organizationID, + Email: email, + Name: name, + CozyURL: inst.PageURL("", nil), + Phone: strings.TrimSpace(phone), + WorkplaceFQDN: strings.TrimSpace(inst.Domain), }, nil } diff --git a/pkg/rabbitmq/org_contacts_test.go b/pkg/rabbitmq/org_contacts_test.go index cd3087afb8e..cebcd48ff8b 100644 --- a/pkg/rabbitmq/org_contacts_test.go +++ b/pkg/rabbitmq/org_contacts_test.go @@ -3,12 +3,12 @@ package rabbitmq_test import ( "errors" "fmt" - "strings" "testing" "time" "github.com/cozy/cozy-stack/model/contact" "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/orgdirectory" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/rabbitmq" @@ -34,10 +34,10 @@ func TestSyncCreatedOrgContact(t *testing.T) { preexisting := createContact(t, bob, "alice@example.com", "https://manual.example", false, "Existing Alice") err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - InternalEmail: "alice@example.com", - Mobile: "+33123456789", - WorkplaceFqdn: target.Domain, - OrganizationDomain: strings.ToUpper(orgDomain) + ".", + InternalEmail: "alice@example.com", + Mobile: "+33123456789", + WorkplaceFqdn: target.Domain, + OrganizationID: orgID, }) require.NoError(t, err) @@ -101,7 +101,35 @@ func TestSyncCreatedOrgContact(t *testing.T) { require.True(t, targetCarolContacts[0].IsTrusted()) }) - t.Run("SkipsExistingExternalContact", func(t *testing.T) { + t.Run("CreatesExternalContactsWithOrganizationDomainOnly", func(t *testing.T) { + config.UseTestFile(t) + testutils.NeedCouchdb(t) + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + orgDomain := "sync-created-domain-" + suffix + ".example" + orgID := "org-sync-created-domain-" + suffix + target := createInstanceInOrg(t, "sync-created-domain-alice-"+suffix+".local", orgDomain, orgID, "alice@example.com", "Alice") + bob := createInstanceInOrg(t, "sync-created-domain-bob-"+suffix+".local", orgDomain, orgID, "bob@example.com", "Bob") + targetURL := target.PageURL("", nil) + + err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ + InternalEmail: "alice@example.com", + WorkplaceFqdn: target.Domain, + OrganizationDomain: orgDomain, + }) + require.NoError(t, err) + + bobContacts, err := contact.FindAllByEmail(bob, "alice@example.com") + require.NoError(t, err) + require.Len(t, bobContacts, 1) + require.True(t, bobContacts[0].IsExternal()) + require.True(t, bobContacts[0].IsTrusted()) + require.Equal(t, "Alice", bobContacts[0].PrimaryName()) + require.Equal(t, targetURL, bobContacts[0].PrimaryCozyURL()) + require.True(t, orgdirectory.IsManagedDirectoryDoc(&bobContacts[0].JSONDoc)) + }) + + t.Run("AdoptsExistingExternalContact", func(t *testing.T) { config.UseTestFile(t) testutils.NeedCouchdb(t) @@ -116,10 +144,10 @@ func TestSyncCreatedOrgContact(t *testing.T) { existing := createContact(t, bob, "alice@example.com", "https://old.example", true, "Old Alice") err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - InternalEmail: "alice@example.com", - Mobile: "+33123456789", - WorkplaceFqdn: target.Domain, - OrganizationDomain: orgDomain, + InternalEmail: "alice@example.com", + Mobile: "+33123456789", + WorkplaceFqdn: target.Domain, + OrganizationID: orgID, }) require.NoError(t, err) @@ -128,9 +156,10 @@ func TestSyncCreatedOrgContact(t *testing.T) { require.Len(t, bobContacts, 1) require.Equal(t, existing.ID(), bobContacts[0].ID()) require.True(t, bobContacts[0].IsExternal()) - require.Equal(t, "Old Alice", bobContacts[0].PrimaryName()) - require.Equal(t, "https://old.example", bobContacts[0].PrimaryCozyURL()) - require.False(t, bobContacts[0].IsTrusted()) + require.Equal(t, "Alice", bobContacts[0].PrimaryName()) + require.Equal(t, targetURL, bobContacts[0].PrimaryCozyURL()) + require.True(t, bobContacts[0].IsTrusted()) + require.True(t, orgdirectory.IsManagedDirectoryDoc(&bobContacts[0].JSONDoc)) carolContacts, err := contact.FindAllByEmail(carol, "alice@example.com") require.NoError(t, err) @@ -154,12 +183,12 @@ func TestSyncCreatedOrgContact(t *testing.T) { createContact(t, bob, "alice@example.com", "https://old-b.example", true, "Alice External B") err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - InternalEmail: "alice@example.com", - WorkplaceFqdn: target.Domain, - OrganizationDomain: orgDomain, + InternalEmail: "alice@example.com", + WorkplaceFqdn: target.Domain, + OrganizationID: orgID, }) require.Error(t, err) - require.Contains(t, err.Error(), "multiple external contacts found for email alice@example.com") + require.Contains(t, err.Error(), "multiple managed contacts found for email alice@example.com") }) t.Run("ContinuesAfterInstanceError", func(t *testing.T) { @@ -177,12 +206,12 @@ func TestSyncCreatedOrgContact(t *testing.T) { createContact(t, bob, "alice@example.com", "https://old-b.example", true, "Alice External B") err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - InternalEmail: "alice@example.com", - WorkplaceFqdn: target.Domain, - OrganizationDomain: orgDomain, + InternalEmail: "alice@example.com", + WorkplaceFqdn: target.Domain, + OrganizationID: orgID, }) require.Error(t, err) - require.Contains(t, err.Error(), "multiple external contacts found for email alice@example.com") + require.Contains(t, err.Error(), "multiple managed contacts found for email alice@example.com") carolContacts, err := contact.FindAllByEmail(carol, "alice@example.com") require.NoError(t, err) @@ -202,8 +231,8 @@ func TestSyncCreatedOrgContact(t *testing.T) { target := createInstanceInOrg(t, "sync-created-missing-email-"+suffix+".local", orgDomain, orgID, "alice@example.com", "Alice") err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - WorkplaceFqdn: target.Domain, - OrganizationDomain: orgDomain, + WorkplaceFqdn: target.Domain, + OrganizationID: orgID, }) require.Error(t, err) require.Contains(t, err.Error(), "missing internalEmail") @@ -220,15 +249,15 @@ func TestSyncCreatedOrgContact(t *testing.T) { clearInstancePublicName(t, target) err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - InternalEmail: "alice@example.com", - WorkplaceFqdn: target.Domain, - OrganizationDomain: orgDomain, + InternalEmail: "alice@example.com", + WorkplaceFqdn: target.Domain, + OrganizationID: orgID, }) require.Error(t, err) require.Contains(t, err.Error(), "missing public_name in settings") }) - t.Run("MissingOrganizationDomain", func(t *testing.T) { + t.Run("MissingOrganizationIDAndDomain", func(t *testing.T) { config.UseTestFile(t) testutils.NeedCouchdb(t) @@ -242,7 +271,7 @@ func TestSyncCreatedOrgContact(t *testing.T) { WorkplaceFqdn: target.Domain, }) require.Error(t, err) - require.Contains(t, err.Error(), "missing organization domain") + require.Contains(t, err.Error(), "missing organizationId or organization domain") }) t.Run("OrganizationHasNoInstances", func(t *testing.T) { @@ -255,9 +284,9 @@ func TestSyncCreatedOrgContact(t *testing.T) { target := createInstanceInOrg(t, "sync-created-zero-"+suffix+".local", orgDomain, orgID, "alice@example.com", "Alice") err := rabbitmq.SyncCreatedOrgContact(testCtx(t), target, rabbitmq.UserCreatedMessage{ - InternalEmail: "alice@example.com", - WorkplaceFqdn: target.Domain, - OrganizationDomain: "missing-sync-created-" + suffix + ".example", + InternalEmail: "alice@example.com", + WorkplaceFqdn: target.Domain, + OrganizationID: "missing-sync-created-" + suffix, }) require.Error(t, err) require.Contains(t, err.Error(), "organization has no instances") @@ -407,7 +436,7 @@ func TestSyncDeletedOrgContact(t *testing.T) { InternalEmail: "alice@example.com", }) require.Error(t, err) - require.Contains(t, err.Error(), "missing organization domain") + require.Contains(t, err.Error(), "missing organizationId or organization domain") }) t.Run("OrganizationHasNoInstances", func(t *testing.T) { diff --git a/pkg/rabbitmq/rabbitmq.go b/pkg/rabbitmq/rabbitmq.go index 51503860296..8c01b7849a3 100644 --- a/pkg/rabbitmq/rabbitmq.go +++ b/pkg/rabbitmq/rabbitmq.go @@ -271,6 +271,8 @@ func BuildExchangeSpecs(exchangesCfg []config.RabbitExchange) []ExchangeSpec { handler = NewDomainSubscriptionChangedHandler() case QueueB2BUserDeleted: handler = NewUserDeletedHandler() + case QueueB2BGroupLifecycle: + handler = NewB2BGroupLifecycleHandler() case QueueAppCommands: handler = NewAppInstallHandler() } diff --git a/pkg/rabbitmq/rabbitmq_test.go b/pkg/rabbitmq/rabbitmq_test.go index 8286136b479..b5632d5d8d2 100644 --- a/pkg/rabbitmq/rabbitmq_test.go +++ b/pkg/rabbitmq/rabbitmq_test.go @@ -999,7 +999,7 @@ func TestUserDeletedHandlerValidation(t *testing.T) { Body: body, }) require.Error(t, err) - require.Contains(t, err.Error(), "missing organization domain") + require.Contains(t, err.Error(), "missing organizationId or organization domain") }) } diff --git a/web/data/data.go b/web/data/data.go index 9648acdff95..98ae8e32cd5 100644 --- a/web/data/data.go +++ b/web/data/data.go @@ -8,11 +8,13 @@ import ( "strconv" "strings" + "github.com/cozy/cozy-stack/model/orgdirectory" "github.com/cozy/cozy-stack/model/permission" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/couchdb/stream" "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/cozy/cozy-stack/pkg/logger" "github.com/cozy/cozy-stack/web/files" "github.com/cozy/cozy-stack/web/middlewares" "github.com/labstack/echo/v4" @@ -48,6 +50,39 @@ func fixErrorNoDatabaseIsWrongDoctype(err error) error { return err } +const managedDirectoryWriteReason = "managed organization directory documents are read-only" + +func managedDirectoryWriteError() error { + return jsonapi.Errorf(http.StatusForbidden, managedDirectoryWriteReason) +} + +func rejectManagedDirectoryCreate(doc *couchdb.JSONDoc) error { + if orgdirectory.IsManagedDirectoryDoctype(doc.DocType()) && orgdirectory.IsManagedDirectoryDoc(doc) { + logger.WithNamespace("data").Warnf("rejecting create on managed organization directory document %s/%s: %s", doc.DocType(), doc.ID(), managedDirectoryWriteReason) + return managedDirectoryWriteError() + } + return nil +} + +func rejectManagedDirectoryUpdate(doc, old *couchdb.JSONDoc) error { + if !orgdirectory.IsManagedDirectoryDoctype(doc.DocType()) { + return nil + } + if orgdirectory.IsManagedDirectoryDoc(doc) || orgdirectory.IsManagedDirectoryDoc(old) { + logger.WithNamespace("data").Warnf("rejecting update on managed organization directory document %s/%s: %s", doc.DocType(), doc.ID(), managedDirectoryWriteReason) + return managedDirectoryWriteError() + } + return nil +} + +func rejectManagedDirectoryDelete(doc *couchdb.JSONDoc) error { + if orgdirectory.IsManagedDirectoryDoctype(doc.DocType()) && orgdirectory.IsManagedDirectoryDoc(doc) { + logger.WithNamespace("data").Warnf("rejecting delete on managed organization directory document %s/%s: %s", doc.DocType(), doc.ID(), managedDirectoryWriteReason) + return managedDirectoryWriteError() + } + return nil +} + func allDoctypes(c echo.Context) error { instance := middlewares.GetInstance(c) @@ -136,6 +171,10 @@ func createDoc(c echo.Context) error { return err } + if err := rejectManagedDirectoryCreate(&doc); err != nil { + return err + } + if err := middlewares.Allow(c, permission.POST, &doc); err != nil { return err } @@ -156,6 +195,10 @@ func createDoc(c echo.Context) error { func createNamedDoc(c echo.Context, doc couchdb.JSONDoc) error { instance := middlewares.GetInstance(c) + if err := rejectManagedDirectoryCreate(&doc); err != nil { + return err + } + err := middlewares.Allow(c, permission.POST, &doc) if err != nil { return err @@ -212,17 +255,28 @@ func UpdateDoc(c echo.Context) error { return createNamedDoc(c, doc) } + if err := rejectManagedDirectoryUpdate(&doc, nil); err != nil { + return err + } + errWhole := middlewares.AllowWholeType(c, permission.PUT, doc.DocType()) - if errWhole != nil { - // we cant apply to whole type, let's fetch old doc and see if it applies there - var old couchdb.JSONDoc - errFetch := couchdb.GetDoc(instance, doc.DocType(), doc.ID(), &old) + var old *couchdb.JSONDoc + if orgdirectory.IsManagedDirectoryDoctype(doc.DocType()) || errWhole != nil { + old = &couchdb.JSONDoc{} + errFetch := couchdb.GetDoc(instance, doc.DocType(), doc.ID(), old) if errFetch != nil { return errFetch } old.Type = doc.DocType() + } + + if err := rejectManagedDirectoryUpdate(&doc, old); err != nil { + return err + } + + if errWhole != nil { // check if permissions set allows manipulating old doc - errOld := middlewares.Allow(c, permission.PUT, &old) + errOld := middlewares.Allow(c, permission.PUT, old) if errOld != nil { return errOld } @@ -280,6 +334,10 @@ func DeleteDoc(c echo.Context) error { doc.Type = doctype doc.SetRev(rev) + if err := rejectManagedDirectoryDelete(&doc); err != nil { + return err + } + err = middlewares.Allow(c, permission.DELETE, &doc) if err != nil { return err diff --git a/web/data/managed_directory_test.go b/web/data/managed_directory_test.go new file mode 100644 index 00000000000..25d6dfa2907 --- /dev/null +++ b/web/data/managed_directory_test.go @@ -0,0 +1,83 @@ +package data + +import ( + "testing" + + "github.com/cozy/cozy-stack/model/orgdirectory" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/stretchr/testify/require" +) + +func TestRejectManagedDirectoryCreate(t *testing.T) { + managed := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{ + orgdirectory.DirectoryMetadataKey: map[string]interface{}{ + "managed": true, + }, + }, + } + require.Error(t, rejectManagedDirectoryCreate(managed)) + + unmanaged := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{"name": "Engineering"}, + } + require.NoError(t, rejectManagedDirectoryCreate(unmanaged)) +} + +func TestRejectManagedDirectoryUpdate(t *testing.T) { + strippedIncoming := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{"name": "Engineering"}, + } + storedManaged := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{ + orgdirectory.DirectoryMetadataKey: map[string]interface{}{ + "managed": true, + }, + }, + } + require.Error(t, rejectManagedDirectoryUpdate(strippedIncoming, storedManaged)) + + incomingManaged := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{ + orgdirectory.DirectoryMetadataKey: map[string]interface{}{ + "managed": true, + }, + }, + } + storedUnmanaged := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{"name": "Engineering"}, + } + require.Error(t, rejectManagedDirectoryUpdate(incomingManaged, storedUnmanaged)) + require.NoError(t, rejectManagedDirectoryUpdate(storedUnmanaged, storedUnmanaged)) +} + +func TestRejectManagedDirectoryDelete(t *testing.T) { + managed := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{ + orgdirectory.DirectoryMetadataKey: map[string]interface{}{ + "managed": true, + }, + }, + } + require.Error(t, rejectManagedDirectoryDelete(managed)) + + unmanaged := &couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{"name": "Engineering"}, + } + require.NoError(t, rejectManagedDirectoryDelete(unmanaged)) +} + +func TestIsManagedDirectoryDoctype(t *testing.T) { + require.True(t, orgdirectory.IsManagedDirectoryDoctype(consts.Contacts)) + require.True(t, orgdirectory.IsManagedDirectoryDoctype(consts.Groups)) + require.False(t, orgdirectory.IsManagedDirectoryDoctype(consts.Files)) +}