Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support using a different s3 bucket through plugin setting #46

Merged
merged 23 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
08e5a8f
add api endpoint to run legal hold job on demand
mickmister May 8, 2024
4bfa0fe
fix lint
mickmister May 9, 2024
07793ac
PR feedback
mickmister May 9, 2024
0fac0cb
call job in goroutine to avoid blocking ServeHTTP hook
mickmister May 9, 2024
bc19ac0
reorder defer blocks for cleaning up legal hold job
mickmister May 15, 2024
54aa700
call context cancel function in defer
mickmister May 15, 2024
e6e186d
move processAllLegalHolds body back into run() method
mickmister May 15, 2024
f3e4a81
support using a different s3 bucket through plugin setting
mickmister May 19, 2024
a926e08
move code around
mickmister May 21, 2024
1b464d7
update readme
mickmister May 21, 2024
c2708a3
Merge remote-tracking branch 'origin/main' into add-api-endpoint-to-r…
wiggin77 May 21, 2024
568f22b
Merge remote-tracking branch 'origin/main' into separate-s3-bucket
wiggin77 May 21, 2024
507a0b3
Merge branch 'add-api-endpoint-to-run-job' into separate-s3-bucket
mickmister May 22, 2024
eb63d08
initial s3 bucket form implementation
mickmister May 23, 2024
71bd47d
test connection works
mickmister May 23, 2024
930e5bf
show success/fail connection messages. check connection on plugin sta…
mickmister May 23, 2024
997fb1b
update readme
mickmister May 23, 2024
135c267
disable inputs instead of hiding
mickmister May 23, 2024
0bb5856
Merge branch 'main' into separate-s3-bucket
wiggin77 May 30, 2024
c54440c
reorder settings in the admin console
mickmister May 30, 2024
849c3ac
show aws secret key as asterisks
mickmister May 30, 2024
d51d58a
remove comment from code
mickmister May 30, 2024
a41faed
Merge branch 'separate-s3-bucket' of https://github.com/mattermost/ma…
mickmister May 30, 2024
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Once the plugin is installed, a new "Legal Hold" section will appear in the Syst
in the Plugins section. There are two main settings:

* **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it.
* **S3 Bucket**: optionally use a separate S3 Bucket than the one configured for your Mattermost server.
* **Time of Day**: this setting controls at what time the delay collection of Legal Hold data
should occur. We recommend choosing a quiet time of day to minimise impact on your users. Make
sure to specify the time in the format shown in the example.
Expand Down
5 changes: 5 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
"help_text": "Time of day to run the Legal Hold task, in the form 'HH:MM ±HHMM' (e.g. '3:00am -0700'). Use +0000 for UTC.",
"default": "1:00am -0700"
},
{
"key": "AmazonS3BucketSettings",
"display_name": "S3 Bucket:",
"type": "custom"
},
{
"key": "LegalHoldsSettings",
"display_name": "Legal Holds:",
Expand Down
68 changes: 63 additions & 5 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gorilla/mux"
mattermostModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
"github.com/pkg/errors"

"github.com/mattermost/mattermost-plugin-legal-hold/server/model"
Expand All @@ -37,11 +38,16 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req

router := mux.NewRouter()

router.HandleFunc("/api/v1/legalhold/list", p.listLegalHolds)
router.HandleFunc("/api/v1/legalhold/create", p.createLegalHold)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/release", p.releaseLegalHold)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/update", p.updateLegalHold)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold)
// Routes called by the plugin's webapp
router.HandleFunc("/api/v1/legalhold/list", p.listLegalHolds).Methods(http.MethodGet)
router.HandleFunc("/api/v1/legalhold/create", p.createLegalHold).Methods(http.MethodPost)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/release", p.releaseLegalHold).Methods(http.MethodPost)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/update", p.updateLegalHold).Methods(http.MethodPost)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet)
router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost)

// Other routes
router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost)

p.router = router
p.router.ServeHTTP(w, r)
Expand Down Expand Up @@ -289,6 +295,58 @@ func (p *Plugin) downloadLegalHold(w http.ResponseWriter, r *http.Request) {
}
}

func (p *Plugin) runJobFromAPI(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte("Processing all Legal Holds. Please check the MM server logs for more details."))
if err != nil {
p.API.LogError("failed to write http response", err.Error())
}

go p.legalHoldJob.RunFromAPI()
}

// we'll want to store the access secret encrypted in the database
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put this comment here since right now the UI will be given the stored access secret on page load. Though there is no chance to encrypt/decrypt the config value in the case of saving config values in the system console. Not sure how to proceed with hiding the secret even though it's in plaintext in the server config, and is exposed to the client directly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is it done in the main mmserver S3 config?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wiggin77 The server's config is sanitized before being returned the client, which in this case includes censoring the main product's AWS secret key. But this sanitization step is not available for plugin settings, so they are always delivered as plaintext.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a ticket to address this #54


// testAmazonS3Connection tests the plugin's custom Amazon S3 connection
func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request) {
type messageResponse struct {
Message string `json:"message"`
}

var err error

conf := p.getConfiguration()
if !conf.AmazonS3BucketSettings.Enable {
http.Error(w, "Amazon S3 bucket settings are not enabled", http.StatusBadRequest)
return
}

filesBackendSettings := FixedFileSettingsToFileBackendSettings(conf.AmazonS3BucketSettings.Settings, false, true)
filesBackend, err := filestore.NewFileBackend(filesBackendSettings)
if err != nil {
err = errors.Wrap(err, "unable to initialize the file store")
http.Error(w, err.Error(), http.StatusInternalServerError)
p.Client.Log.Error(err.Error())
return
}

if err = filesBackend.TestConnection(); err != nil {
err = errors.Wrap(err, "failed to connect to Amazon S3 bucket")
http.Error(w, err.Error(), http.StatusInternalServerError)
p.Client.Log.Error(err.Error())
return
}

response := messageResponse{
Message: "Successfully connected to Amazon S3 bucket",
}

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

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

Expand Down
61 changes: 60 additions & 1 deletion server/api_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,62 @@
package main

// TODO: Implement me!
import (
"net/http"
"net/http/httptest"
"testing"

pluginapi "github.com/mattermost/mattermost-plugin-api"
"github.com/mattermost/mattermost-plugin-legal-hold/server/config"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestTestAmazonS3Connection(t *testing.T) {
p := &Plugin{}
api := &plugintest.API{}
p.SetDriver(&plugintest.Driver{})
p.SetAPI(api)
p.Client = pluginapi.NewClient(p.API, p.Driver)

api.On("HasPermissionTo", "test_user_id", model.PermissionManageSystem).Return(true)
api.On("LogInfo", mock.Anything).Maybe()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/bucket/" {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))

defer server.Close()

p.setConfiguration(&config.Configuration{
TimeOfDay: "10:00pm -0500h",
AmazonS3BucketSettings: config.AmazonS3BucketSettings{
Enable: true,
Settings: model.FileSettings{
DriverName: model.NewString("amazons3"),
AmazonS3Bucket: model.NewString("bucket"),
AmazonS3AccessKeyId: model.NewString("access_key_id"),
AmazonS3SecretAccessKey: model.NewString("secret_access_key"),
AmazonS3RequestTimeoutMilliseconds: model.NewInt64(5000),
AmazonS3Endpoint: model.NewString(server.Listener.Addr().String()),
AmazonS3Region: model.NewString("us-east-1"),
AmazonS3SSL: model.NewBool(false),
AmazonS3SSE: model.NewBool(false),
},
},
})

req, err := http.NewRequest(http.MethodPost, "/api/v1/test_amazon_s3_connection", nil)
require.NoError(t, err)

req.Header.Add("Mattermost-User-Id", "test_user_id")

recorder := httptest.NewRecorder()
p.ServeHTTP(nil, recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
}
10 changes: 9 additions & 1 deletion server/config/configuration.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package config

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

// Configuration captures the plugin's external Configuration as exposed in the Mattermost server
// Configuration, as well as values computed from the Configuration. Any public fields will be
// deserialized from the Mattermost server Configuration in OnConfigurationChange.
Expand All @@ -12,7 +14,13 @@ package config
// If you add non-reference types to your Configuration struct, be sure to rewrite Clone as a deep
// copy appropriate for your types.
type Configuration struct {
TimeOfDay string
TimeOfDay string
AmazonS3BucketSettings AmazonS3BucketSettings
}

type AmazonS3BucketSettings struct {
Enable bool
Settings model.FileSettings
}

// Clone shallow copies the Configuration. Your implementation may require a deep copy if
Expand Down
34 changes: 22 additions & 12 deletions server/jobs/legal_hold_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,20 @@ func (j *LegalHoldJob) nextWaitInterval(now time.Time, metaData cluster.JobMetad
return delta
}

func (j *LegalHoldJob) RunFromAPI() {
j.run()
}

func (j *LegalHoldJob) run() {
j.mux.Lock()
oldRunner := j.runner
j.mux.Unlock()

if oldRunner != nil {
j.client.Log.Error("Multiple Legal Hold jobs scheduled concurrently; there can be only one")
return
}

j.client.Log.Info("Running Legal Hold Job")
exitSignal := make(chan struct{})
ctx, canceller := context.WithCancel(context.Background())
Expand All @@ -158,25 +171,22 @@ func (j *LegalHoldJob) run() {
exitSignal: exitSignal,
}

var oldRunner *runInstance
var settings *LegalHoldJobSettings
j.mux.Lock()
oldRunner = j.runner
j.runner = runner
settings = j.settings.Clone()
j.mux.Unlock()

defer func() {
canceller()
close(exitSignal)

j.mux.Lock()
j.runner = nil
j.mux.Unlock()
}()

if oldRunner != nil {
j.client.Log.Error("Multiple Legal Hold jobs scheduled concurrently; there can be only one")
return
}
var settings *LegalHoldJobSettings
j.mux.Lock()
j.runner = runner
settings = j.settings.Clone()
j.mux.Unlock()

j.client.Log.Info("Processing all Legal Holds")

// Retrieve the legal holds from the store.
legalHolds, err := j.kvstore.GetAllLegalHolds()
Expand Down
16 changes: 15 additions & 1 deletion server/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,28 @@ func (p *Plugin) Reconfigure() error {
return nil
}

conf := p.getConfiguration()

serverFileSettings := p.Client.Configuration.GetUnsanitizedConfig().FileSettings
if conf.AmazonS3BucketSettings.Enable {
serverFileSettings = conf.AmazonS3BucketSettings.Settings
}

// Reinitialise the filestore backend
// FIXME: Boolean flags shouldn't be hard coded.
filesBackendSettings := FixedFileSettingsToFileBackendSettings(p.Client.Configuration.GetUnsanitizedConfig().FileSettings, false, true)
filesBackendSettings := FixedFileSettingsToFileBackendSettings(serverFileSettings, false, true)
filesBackend, err := filestore.NewFileBackend(filesBackendSettings)
if err != nil {
p.Client.Log.Error("unable to initialize the files storage", "err", err)
return errors.New("unable to initialize the files storage")
}

if err = filesBackend.TestConnection(); err != nil {
err = errors.Wrap(err, "connection test for filestore failed")
p.Client.Log.Error(err.Error())
return err
}

p.FileBackend = filesBackend

// Remove old job if exists
Expand Down
9 changes: 7 additions & 2 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ class APIClient {
return this.doPost(url, data);
};

doGet = async (url: string, headers = {}) => {
testAmazonS3Connection = () => {
const url = `${this.url}/test_amazon_s3_connection`;
return this.doPost(url, {}) as Promise<{message: string}>;
};

private doGet = async (url: string, headers = {}) => {
const options = {
method: 'get',
headers,
Expand All @@ -53,7 +58,7 @@ class APIClient {
});
};

doPost = async (url: string, body: any, headers = {}) => {
private doPost = async (url: string, body: any, headers = {}) => {
const options = {
method: 'post',
body: JSON.stringify(body),
Expand Down
32 changes: 32 additions & 0 deletions webapp/src/components/admin_console_settings/base_setting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';

type Props = React.PropsWithChildren<{
id: string;
name: string;
helpText: string;
}>;

const BaseSetting = (props: Props) => {
return (
<div
id={`legal-hold-admin-console-setting-${props.id}`}
className='form-group'
>
<label
className='control-label col-sm-4'
>
{props.name && `${props.name}:`}
</label>
<div className='col-sm-8'>
{props.children}
<div
className='help-text'
>
<span>{props.helpText}</span>
</div>
</div>
</div>
);
};

export default BaseSetting;
41 changes: 41 additions & 0 deletions webapp/src/components/admin_console_settings/boolean_setting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';

import BaseSetting from './base_setting';

type Props = {
id: string;
name: string;
helpText: string;
onChange: (value: boolean) => void;
value: boolean;
disabled?: boolean;
};

const BooleanSetting = (props: Props) => {
return (
<BaseSetting
{...props}
>
<label className='radio-inline'>
<input
type='radio'
onChange={() => props.onChange(true)}
checked={props.value}
disabled={props.disabled}
/>
<span>{'true'}</span>
</label>
<label className='radio-inline'>
<input
type='radio'
onChange={() => props.onChange(false)}
checked={!props.value}
disabled={props.disabled}
/>
<span>{'false'}</span>
</label>
</BaseSetting>
);
};

export default BooleanSetting;
Loading
Loading