-
Notifications
You must be signed in to change notification settings - Fork 38
feat: Added SMTP extension #159
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
# SMTP Fan-out Extension | ||
|
||
With this extension, you can send an email for every workflow run and attestation. | ||
|
||
## How to use it | ||
|
||
In the following example, we will use the AWS SES service. | ||
|
||
1. To get started, you need to register the extension in your Chainloop organization. | ||
``` | ||
chainloop integration registered add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd****,host=email-smtp.us-east-1.amazonaws.com,port=587,to=platform-team@example.com,from=notifier@example.com | ||
``` | ||
|
||
2. When attaching the integration to your workflow, you have the option to specify CC: | ||
|
||
``` | ||
chainloop workflow integration attach --workflow $WID --integration $IID --options cc=security@example.com | ||
``` | ||
|
||
cc is optional: | ||
|
||
``` | ||
chainloop workflow integration attach --workflow $WID --integration $IID | ||
``` | ||
|
||
Starting now, every time a workflow run occurs, an email notification will be sent containing the details of the run and attestation. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,229 @@ | ||
// | ||
// Copyright 2023 The Chainloop Authors. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package smtp | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
nsmtp "net/smtp" | ||
|
||
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1" | ||
"github.com/go-kratos/kratos/v2/log" | ||
"github.com/in-toto/in-toto-golang/in_toto" | ||
) | ||
|
||
type Integration struct { | ||
*sdk.FanOutIntegration | ||
} | ||
|
||
type registrationRequest struct { | ||
To string `json:"to" jsonschema:"format=email,description=The email address to send the email to."` | ||
From string `json:"from" jsonschema:"format=email,description=The email address of the sender."` | ||
User string `json:"user" jsonschema:"minLength=1,description=The username to use for the SMTP authentication."` | ||
Password string `json:"password" jsonschema:"description=The password to use for the SMTP authentication."` | ||
Host string `json:"host" jsonschema:"description=The host to use for the SMTP authentication."` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would any of these format help? https://json-schema.org/understanding-json-schema/reference/string.html#hostnames There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not supported by our jsonschema library - I can work on the PR though. We would have to use something like: to get
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, that makes sense. It's not just a host but also an IP address. |
||
// TODO: Make the port an integer | ||
Port string `json:"port" jsonschema:"description=The port to use for the SMTP authentication"` | ||
} | ||
|
||
type registrationState struct { | ||
To string `json:"to"` | ||
From string `json:"from"` | ||
User string `json:"user"` | ||
Host string `json:"host"` | ||
Port string `json:"port"` | ||
} | ||
|
||
type attachmentRequest struct { | ||
CC string `json:"cc,omitempty" jsonschema:"format=email,description=The email address of the carbon copy recipient."` | ||
} | ||
|
||
type attachmentState struct { | ||
CC string `json:"cc"` | ||
} | ||
|
||
func New(l log.Logger) (sdk.FanOut, error) { | ||
base, err := sdk.NewFanOut( | ||
&sdk.NewParams{ | ||
ID: "smtp", | ||
Version: "0.1", | ||
danlishka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Logger: l, | ||
InputSchema: &sdk.InputSchema{ | ||
Registration: registrationRequest{}, | ||
Attachment: attachmentRequest{}, | ||
}, | ||
}, | ||
sdk.WithEnvelope(), | ||
) | ||
|
||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return &Integration{base}, nil | ||
} | ||
|
||
// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization | ||
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) { | ||
i.Logger.Info("registration requested") | ||
|
||
// Unmarshal the request | ||
var request *registrationRequest | ||
if err := sdk.FromConfig(req.Payload, &request); err != nil { | ||
return nil, fmt.Errorf("invalid registration request: %w", err) | ||
} | ||
|
||
to, from, user, password, host, port := request.To, request.From, request.User, request.Password, request.Host, request.Port | ||
|
||
// validate and notify | ||
subject := "[chainloop] New SMTP integration added!" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, so this is in fact sending an email during registration. That's an interesting approach. If we just wanted to check that the credentials are correct, is there any other way to check it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this way, you really make sure you receive an email. It might still go to spam, or the |
||
tpl := ` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be ideal to use go-templates instead https://pkg.go.dev/text/template, it can be in another patch. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the SMTP logic will also be moved to a separate package. |
||
We successfully registered a new SMTP integration in your Chainloop organization. | ||
|
||
Extension: %s version: %s | ||
- Host: %s | ||
- Port: %s | ||
- User: %s | ||
- From: %s | ||
- To: %s | ||
` | ||
body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to) | ||
err := sendEmail(host, port, user, password, from, to, "", subject, body) | ||
if err != nil { | ||
return nil, fmt.Errorf("sending an email: %w", err) | ||
} | ||
|
||
response := &sdk.RegistrationResponse{} | ||
rawConfig, err := sdk.ToConfig(®istrationState{To: to, From: from, User: user, Host: host, Port: port}) | ||
if err != nil { | ||
return nil, fmt.Errorf("marshalling configuration: %w", err) | ||
} | ||
response.Configuration = rawConfig | ||
response.Credentials = &sdk.Credentials{Password: password} | ||
|
||
return response, nil | ||
} | ||
|
||
// Attachment is executed when to attach a registered instance of this integration to a specific workflow | ||
func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) { | ||
i.Logger.Info("attachment requested") | ||
|
||
// Parse the request that has already been validated against the input schema | ||
var request *attachmentRequest | ||
if err := sdk.FromConfig(req.Payload, &request); err != nil { | ||
return nil, fmt.Errorf("invalid attachment request: %w", err) | ||
} | ||
|
||
response := &sdk.AttachmentResponse{} | ||
rawConfig, err := sdk.ToConfig(&attachmentState{CC: request.CC}) | ||
if err != nil { | ||
return nil, fmt.Errorf("marshalling configuration: %w", err) | ||
} | ||
response.Configuration = rawConfig | ||
|
||
return response, nil | ||
} | ||
|
||
// Send the SBOM to the configured Dependency Track instance | ||
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error { | ||
i.Logger.Info("execution requested") | ||
|
||
if err := validateExecuteRequest(req); err != nil { | ||
return fmt.Errorf("running validation for workflow id %s: %w", req.WorkflowID, err) | ||
} | ||
|
||
var rc *registrationState | ||
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil { | ||
return errors.New("invalid registration configuration") | ||
} | ||
|
||
var ac *attachmentState | ||
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &ac); err != nil { | ||
return errors.New("invalid attachment configuration") | ||
} | ||
|
||
// get the attestation | ||
decodedPayload, err := req.Input.DSSEnvelope.DecodeB64Payload() | ||
if err != nil { | ||
return err | ||
} | ||
statement := &in_toto.Statement{} | ||
if err := json.Unmarshal(decodedPayload, statement); err != nil { | ||
return fmt.Errorf("un-marshaling predicate: %w", err) | ||
} | ||
jsonBytes, err := json.MarshalIndent(statement, "", " ") | ||
if err != nil { | ||
return fmt.Errorf("error marshaling JSON: %w", err) | ||
} | ||
|
||
// send the email | ||
to, from, user, password, host, port := rc.To, rc.From, rc.User, req.RegistrationInfo.Credentials.Password, rc.Host, rc.Port | ||
subject := "[chainloop] New workflow run finished successfully!" | ||
tpl := `A new workflow run finished successfully! | ||
|
||
# Workflow: %s | ||
|
||
# in-toto statement: | ||
%s | ||
|
||
This email has been delivered via integration %s version %s. | ||
` | ||
body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version) | ||
err = sendEmail(host, port, user, password, from, to, ac.CC, subject, body) | ||
if err != nil { | ||
return fmt.Errorf("sending an email: %w", err) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func validateExecuteRequest(req *sdk.ExecutionRequest) error { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mental note for myself: this is starting to look more and more like smth that could get automatically validated. |
||
if req == nil || req.Input == nil || req.Input.DSSEnvelope == nil { | ||
return errors.New("invalid input") | ||
} | ||
|
||
if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil { | ||
return errors.New("missing registration configuration") | ||
} | ||
|
||
if req.RegistrationInfo.Credentials == nil { | ||
return errors.New("missing credentials") | ||
} | ||
|
||
if req.AttachmentInfo == nil || req.AttachmentInfo.Configuration == nil { | ||
return errors.New("missing attachment configuration") | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func sendEmail(host string, port string, user, password, from, to, cc, subject, body string) error { | ||
danlishka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
message := "From: " + from + "\n" + | ||
"To: " + to + "\n" + | ||
"CC: " + cc + "\n" + | ||
"Subject: " + subject + "\n\n" + | ||
body | ||
|
||
auth := nsmtp.PlainAuth("", user, password, host) | ||
err := nsmtp.SendMail(host+":"+port, auth, from, []string{to}, []byte(message)) | ||
if err != nil { | ||
return fmt.Errorf("error sending email: %w", err) | ||
} | ||
|
||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
// | ||
// Copyright 2023 The Chainloop Authors. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
package smtp | ||
|
||
import ( | ||
"encoding/json" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestValidateRegistrationInput(t *testing.T) { | ||
testCases := []struct { | ||
name string | ||
input map[string]interface{} | ||
errMsg string | ||
}{ | ||
{ | ||
name: "missing properties", | ||
input: map[string]interface{}{}, | ||
errMsg: "missing properties: 'to', 'from', 'user'", | ||
}, | ||
{ | ||
name: "valid request", | ||
input: map[string]interface{}{"from": "test@example.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"}, | ||
}, | ||
{ | ||
name: "invalid email", | ||
input: map[string]interface{}{"from": "testexample.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"}, | ||
errMsg: "is not valid 'email'", | ||
}, | ||
} | ||
|
||
integration, err := New(nil) | ||
require.NoError(t, err) | ||
|
||
for _, tc := range testCases { | ||
t.Run(tc.name, func(t *testing.T) { | ||
payload, err := json.Marshal(tc.input) | ||
require.NoError(t, err) | ||
err = integration.ValidateRegistrationRequest(payload) | ||
if tc.errMsg != "" { | ||
assert.ErrorContains(t, err, tc.errMsg) | ||
} else { | ||
assert.NoError(t, err) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestValidateAttachmentInput(t *testing.T) { | ||
testCases := []struct { | ||
name string | ||
input map[string]interface{} | ||
errMsg string | ||
}{ | ||
{ | ||
name: "valid with no optional cc", | ||
input: map[string]interface{}{}, | ||
}, | ||
{ | ||
name: "valid cc format", | ||
input: map[string]interface{}{"cc": "test@example.com"}, | ||
}, | ||
{ | ||
name: "invalid cc format", | ||
input: map[string]interface{}{"cc": "testexample.com"}, | ||
errMsg: "is not valid 'email'", | ||
}, | ||
} | ||
|
||
integration, err := New(nil) | ||
require.NoError(t, err) | ||
|
||
for _, tc := range testCases { | ||
t.Run(tc.name, func(t *testing.T) { | ||
payload, err := json.Marshal(tc.input) | ||
require.NoError(t, err) | ||
err = integration.ValidateAttachmentRequest(payload) | ||
if tc.errMsg != "" { | ||
assert.ErrorContains(t, err, tc.errMsg) | ||
} else { | ||
assert.NoError(t, err) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestNewIntegration(t *testing.T) { | ||
_, err := New(nil) | ||
assert.NoError(t, err) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.