Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions app/controlplane/extensions/core/smtp/v1/README.md
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.
229 changes: 229 additions & 0 deletions app/controlplane/extensions/core/smtp/v1/extension.go
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."`
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

The 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: anyof_format=hostname,ipv4

to get

"anyOf": [
{ "format": "hostname" },
{ "format": "ipv4" },
{ "format": "ipv6" }
]

Copy link
Member

Choose a reason for hiding this comment

The 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",
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!"
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The 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 to address might be incorrect, or the from address could be rejected by the SMTP service, etc.

tpl := `
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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(&registrationState{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 {
Copy link
Member

Choose a reason for hiding this comment

The 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 {
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
}
106 changes: 106 additions & 0 deletions app/controlplane/extensions/core/smtp/v1/extension_test.go
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)
}
2 changes: 2 additions & 0 deletions app/controlplane/extensions/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package extensions

import (
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/dependencytrack/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/smtp/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1"
"github.com/chainloop-dev/chainloop/internal/servicelogger"
"github.com/go-kratos/kratos/v2/log"
Expand All @@ -32,6 +33,7 @@ func Load(l log.Logger) (sdk.AvailableExtensions, error) {
// Eventually this will be dynamically loaded from a directory
toEnable := []sdk.FanOutFactory{
dependencytrack.New,
smtp.New,
}

// Initialize and load the extensions
Expand Down