Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2241c02
Ignore aider files
davidkrauser Mar 17, 2025
511d72e
Add API to search for groups by display name
davidkrauser Mar 17, 2025
afa23cd
Don't loop forever on job execution error
davidkrauser Mar 17, 2025
62319cf
Add group IDs to legal hold struct
davidkrauser Mar 17, 2025
07e8c0d
Process group members in legal hold execution
davidkrauser Mar 17, 2025
522a525
Add group input component with name autocomplete
davidkrauser Mar 17, 2025
8975922
Add API call to retrieve relevant group info
davidkrauser Mar 17, 2025
3cff45a
Add group input fields to legalhold forms
davidkrauser Mar 17, 2025
eea0ae0
Show group information in legalhold table
davidkrauser Mar 17, 2025
12ca57f
Fix lint issues
davidkrauser Mar 18, 2025
a1eed90
Fix failing test
davidkrauser Mar 20, 2025
88b47cd
Move searchGroups to client.ts
davidkrauser Mar 25, 2025
a1bcfab
Drop unused import
davidkrauser Mar 25, 2025
d8ed3c8
Comment complex map/reduce/filter
davidkrauser Mar 25, 2025
2b53683
Fix lint errors
davidkrauser Mar 25, 2025
437fe94
Search should only return LDAP groups
davidkrauser Mar 31, 2025
ba07b45
Specify groups are LDAP in frontend labels
davidkrauser Mar 31, 2025
1aca911
Support searching LDAP groups by @name
davidkrauser Mar 31, 2025
988d43e
Merge branch 'main' into specify-with-groups
davidkrauser Apr 1, 2025
08de4ac
Merge branch 'main' into specify-with-groups
davidkrauser Apr 2, 2025
1ea8ffb
Update license request with new required fields
davidkrauser Apr 2, 2025
b627921
Revert "Update license request with new required fields"
davidkrauser Apr 10, 2025
7edc185
Merge branch 'main' into specify-with-groups
davidkrauser Apr 10, 2025
15d9b9e
Merge branch 'main' into specify-with-groups
davidkrauser Jun 18, 2025
38f7e44
Fix broken bits from merge
davidkrauser Jun 19, 2025
fae7d23
Fix lint issues
davidkrauser Jun 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ dist/
.idea/

processor/temp
.aider*
.aider*
23 changes: 23 additions & 0 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"slices"
"strings"
"time"

"github.com/gorilla/mux"
Expand Down Expand Up @@ -47,6 +48,7 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req
router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet)
router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}/run", p.runSingleLegalHold).Methods(http.MethodPost)
router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost)
router.HandleFunc("/api/v1/groups/search", p.searchLDAPGroups).Methods(http.MethodGet)

// Other routes
router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost)
Expand Down Expand Up @@ -406,6 +408,27 @@ func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request)
}
}

// searchLDAPGroups searches for groups by display name
func (p *Plugin) searchLDAPGroups(w http.ResponseWriter, r *http.Request) {
prefix := strings.TrimSpace(r.URL.Query().Get("prefix"))
if prefix == "" {
http.Error(w, "missing search prefix", http.StatusBadRequest)
return
}

groups, err := p.SQLStore.SearchLDAPGroupsByPrefix(prefix)
if err != nil {
http.Error(w, "failed to search groups", http.StatusInternalServerError)
p.Client.Log.Error("failed to search groups", "error", err.Error())
return
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(groups); err != nil {
p.Client.Log.Error("failed to write http response", "error", err.Error())
}
}

func RequireLegalHoldID(r *http.Request) (string, error) {
props := mux.Vars(r)

Expand Down
62 changes: 57 additions & 5 deletions server/legalhold/legal_hold.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/gocarina/gocsv"
"github.com/mattermost/mattermost-plugin-api/cluster"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/filestore"

Expand Down Expand Up @@ -111,13 +112,38 @@ func (ex *Execution) Execute(now int64) (*model.LegalHold, error) {
// GetChannels populates the list of channels that the Execution needs to cover within the
// internal state of the Execution struct.
func (ex *Execution) GetChannels() error {
targetUsers, appErr := getUsersForGroups(ex.papi, ex.LegalHold.GroupIDs)
if appErr != nil {
return appErr
}

for _, userID := range ex.LegalHold.UserIDs {
user, appErr := ex.papi.GetUser(userID)
if appErr != nil {
return appErr
}
targetUsers = append(targetUsers, user)
}

// keep track of which users have been processed
processedUsersList := make(map[string]struct{})
Comment thread
davidkrauser marked this conversation as resolved.
// processAndMarkUser is a helper function that will check if a user
// has been processed, and mark the user if they has not.
// Returns true if the user should be processed
processAndMarkUser := func(id string) bool {
if _, processed := processedUsersList[id]; processed {
return false
}
processedUsersList[id] = struct{}{}
return true
}

for _, user := range targetUsers {
if !processAndMarkUser(user.Id) {
continue
}

channelIDs, err := ex.store.GetChannelIDsForUserDuring(userID, ex.ExecutionStartTime, ex.ExecutionEndTime, ex.LegalHold.IncludePublicChannels)
channelIDs, err := ex.store.GetChannelIDsForUserDuring(user.Id, ex.ExecutionStartTime, ex.ExecutionEndTime, ex.LegalHold.IncludePublicChannels)
if err != nil {
return err
}
Expand All @@ -126,16 +152,16 @@ func (ex *Execution) GetChannels() error {

ex.papi.LogDebug(
"Legal hold executor - GetChannels",
"user_id", userID,
"user_id", user.Id,
"channel_count", len(channelIDs),
"start_time", ex.ExecutionStartTime,
"end_time", ex.ExecutionEndTime,
)

// Add to channels index
for _, channelID := range channelIDs {
if idx, ok := ex.index.Users[userID]; !ok {
ex.index.Users[userID] = model.LegalHoldIndexUser{
if idx, ok := ex.index.Users[user.Id]; !ok {
ex.index.Users[user.Id] = model.LegalHoldIndexUser{
Username: user.Username,
Email: user.Email,
Channels: []model.LegalHoldChannelMembership{
Expand All @@ -147,7 +173,7 @@ func (ex *Execution) GetChannels() error {
},
}
} else {
ex.index.Users[userID] = model.LegalHoldIndexUser{
ex.index.Users[user.Id] = model.LegalHoldIndexUser{
Username: user.Username,
Email: user.Email,
Channels: append(idx.Channels, model.LegalHoldChannelMembership{
Expand Down Expand Up @@ -496,3 +522,29 @@ func hashFromReader(secret string, reader io.Reader) (string, error) {

return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}

func getUsersForGroups(api plugin.API, groupIDs []string) ([]*mm_model.User, error) {
const GroupPageLimit = 100
const GroupPageSize = 50

var allUsers []*mm_model.User
for _, groupID := range groupIDs {
currPage := 0
for {
users, appErr := api.GetGroupMemberUsers(groupID, currPage, GroupPageSize)
if appErr != nil {
return nil, appErr
}
if currPage > GroupPageLimit {
return nil, fmt.Errorf("cannot execute legal hold: a group (%s) exceeds the maximum number of members (%d)", groupID, GroupPageLimit*GroupPageSize)
}
if len(users) < 1 {
break
}
allUsers = append(allUsers, users...)
currPage++
}
}

return allUsers, nil
}
6 changes: 3 additions & 3 deletions server/model/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ func getLegalHoldChannelMembership(channelMemberships []LegalHoldChannelMembersh

// Combine combines the data from two LegalHoldChannelMembership instances and returns a new one
// representing the combined data.
func (lhcm LegalHoldChannelMembership) Combine(new LegalHoldChannelMembership) LegalHoldChannelMembership {
func (lhcm LegalHoldChannelMembership) Combine(newMembership LegalHoldChannelMembership) LegalHoldChannelMembership {
return LegalHoldChannelMembership{
ChannelID: lhcm.ChannelID,
StartTime: utils.Min(lhcm.StartTime, new.StartTime),
EndTime: utils.Max(lhcm.EndTime, new.EndTime),
StartTime: utils.Min(lhcm.StartTime, newMembership.StartTime),
EndTime: utils.Max(lhcm.EndTime, newMembership.EndTime),
}
}
30 changes: 26 additions & 4 deletions server/model/legal_hold.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type LegalHold struct {
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
UserIDs []string `json:"user_ids"`
GroupIDs []string `json:"group_ids"`
StartsAt int64 `json:"starts_at"`
EndsAt int64 `json:"ends_at"`
IncludePublicChannels bool `json:"include_public_channels"`
Expand Down Expand Up @@ -68,6 +69,11 @@ func (lh *LegalHold) DeepCopy() LegalHold {
copy(newLegalHold.UserIDs, lh.UserIDs)
}

if len(lh.GroupIDs) > 0 {
newLegalHold.GroupIDs = make([]string, len(lh.GroupIDs))
copy(newLegalHold.GroupIDs, lh.GroupIDs)
}

return newLegalHold
}

Expand All @@ -93,8 +99,8 @@ func (lh *LegalHold) IsValidForCreate() error {
return errors.New("LegalHold display name must be between 2 and 64 characters in length")
}

if len(lh.UserIDs) < 1 {
return errors.New("LegalHold must include at least 1 user")
if len(lh.UserIDs) < 1 && len(lh.GroupIDs) < 1 {
return errors.New("LegalHold must include at least 1 user or 1 group")
}

for _, userID := range lh.UserIDs {
Expand All @@ -103,6 +109,12 @@ func (lh *LegalHold) IsValidForCreate() error {
}
}

for _, groupID := range lh.GroupIDs {
if !mattermostModel.IsValidId(groupID) {
return errors.New("LegalHold groups must have valid IDs")
}
}

if lh.StartsAt < 1 {
return errors.New("LegalHold must start at a valid time")
}
Expand Down Expand Up @@ -163,6 +175,7 @@ type CreateLegalHold struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
UserIDs []string `json:"user_ids"`
GroupIDs []string `json:"group_ids"`
StartsAt int64 `json:"starts_at"`
EndsAt int64 `json:"ends_at"`
IncludePublicChannels bool `json:"include_public_channels"`
Expand All @@ -176,6 +189,7 @@ func NewLegalHoldFromCreate(lhc CreateLegalHold) LegalHold {
Name: lhc.Name,
DisplayName: lhc.DisplayName,
UserIDs: lhc.UserIDs,
GroupIDs: lhc.GroupIDs,
StartsAt: lhc.StartsAt,
EndsAt: lhc.EndsAt,
IncludePublicChannels: lhc.IncludePublicChannels,
Expand All @@ -189,6 +203,7 @@ type UpdateLegalHold struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
UserIDs []string `json:"user_ids"`
GroupIDs []string `json:"group_ids"`
IncludePublicChannels bool `json:"include_public_channels"`
EndsAt int64 `json:"ends_at"`
}
Expand All @@ -202,8 +217,8 @@ func (ulh UpdateLegalHold) IsValid() error {
return errors.New("LegalHold display name must be between 2 and 64 characters in length")
}

if len(ulh.UserIDs) < 1 {
return errors.New("LegalHold must include at least 1 user")
if len(ulh.UserIDs) < 1 && len(ulh.GroupIDs) < 1 {
return errors.New("LegalHold must include at least 1 user or 1 group")
}

for _, userID := range ulh.UserIDs {
Expand All @@ -212,6 +227,12 @@ func (ulh UpdateLegalHold) IsValid() error {
}
}

for _, groupID := range ulh.GroupIDs {
if !mattermostModel.IsValidId(groupID) {
return errors.New("LegalHold groups must have valid IDs")
}
}

if ulh.EndsAt < 0 {
return errors.New("LegalHold must end at a valid time or zero")
}
Expand All @@ -222,6 +243,7 @@ func (ulh UpdateLegalHold) IsValid() error {
func (lh *LegalHold) ApplyUpdates(updates UpdateLegalHold) {
lh.DisplayName = updates.DisplayName
lh.UserIDs = updates.UserIDs
lh.GroupIDs = updates.GroupIDs
lh.EndsAt = updates.EndsAt
lh.IncludePublicChannels = updates.IncludePublicChannels
}
2 changes: 1 addition & 1 deletion server/model/legal_hold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ func TestModel_UpdateLegalHold_IsValid(t *testing.T) {
UserIDs: []string{},
EndsAt: 0,
},
expected: "LegalHold must include at least 1 user",
expected: "LegalHold must include at least 1 user or 1 group",
},
{
name: "InvalidUserIDs",
Expand Down
54 changes: 54 additions & 0 deletions server/store/sqlstore/group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package sqlstore

import (
"strings"

sq "github.com/Masterminds/squirrel"
"github.com/pkg/errors"

"github.com/mattermost/mattermost-server/v6/model"
)

var escapeLikeSearchChar = []string{
"%",
"_",
}

func sanitizeSearchTerm(term string) string {
const escapeChar = "\\"

term = strings.ReplaceAll(term, escapeChar, "")

for _, c := range escapeLikeSearchChar {
term = strings.ReplaceAll(term, c, escapeChar+c)
}

return term
}

func (ss SQLStore) SearchLDAPGroupsByPrefix(prefix string) ([]*model.Group, error) {
sanitizedPrefix := strings.ToLower(sanitizeSearchTerm(prefix))
query := ss.replicaBuilder.
Select("Id", "Name", "DisplayName", "DeleteAt").
From("UserGroups").
Where(sq.Or{
sq.Like{"LOWER(DisplayName)": sanitizedPrefix + "%"},
sq.Like{"LOWER(Name)": sanitizedPrefix + "%"},
}).
Where(sq.Eq{"DeleteAt": 0}).
Comment thread
davidkrauser marked this conversation as resolved.
Where(sq.Eq{"Source": "ldap"}).
OrderBy("DisplayName").
Limit(10)

sql, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to build SQL query for searching groups")
}

var groups []*model.Group
if err := ss.replica.Select(&groups, sql, args...); err != nil {
return nil, errors.Wrap(err, "failed to search groups")
}

return groups, nil
}
10 changes: 10 additions & 0 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ class APIClient {
return this.doWithBody(url, 'post', {}) as Promise<{message: string}>;
};

getGroup = (id: string) => {
const url = `/api/v4/groups/${id}`;
return this.doGet(url);
};

searchGroups = (term: string) => {
const url = `${this.url}/groups/search?prefix=${encodeURIComponent(term)}`;
return this.doGet(url);
};

private doGet = async (url: string, headers = {}) => {
const options = {
method: 'get',
Expand Down
Loading
Loading