Skip to content

Commit 700eb42

Browse files
authored
feat: Added SMTP extension (#159)
Signed-off-by: Daniel Liszka <daniel@chainloop.dev>
1 parent 2ae0e82 commit 700eb42

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# SMTP Fan-out Extension
2+
3+
With this extension, you can send an email for every workflow run and attestation.
4+
5+
## How to use it
6+
7+
In the following example, we will use the AWS SES service.
8+
9+
1. To get started, you need to register the extension in your Chainloop organization.
10+
```
11+
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
12+
```
13+
14+
2. When attaching the integration to your workflow, you have the option to specify CC:
15+
16+
```
17+
chainloop workflow integration attach --workflow $WID --integration $IID --options cc=security@example.com
18+
```
19+
20+
cc is optional:
21+
22+
```
23+
chainloop workflow integration attach --workflow $WID --integration $IID
24+
```
25+
26+
Starting now, every time a workflow run occurs, an email notification will be sent containing the details of the run and attestation.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package smtp
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
nsmtp "net/smtp"
24+
25+
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1"
26+
"github.com/go-kratos/kratos/v2/log"
27+
"github.com/in-toto/in-toto-golang/in_toto"
28+
)
29+
30+
type Integration struct {
31+
*sdk.FanOutIntegration
32+
}
33+
34+
type registrationRequest struct {
35+
To string `json:"to" jsonschema:"format=email,description=The email address to send the email to."`
36+
From string `json:"from" jsonschema:"format=email,description=The email address of the sender."`
37+
User string `json:"user" jsonschema:"minLength=1,description=The username to use for the SMTP authentication."`
38+
Password string `json:"password" jsonschema:"description=The password to use for the SMTP authentication."`
39+
Host string `json:"host" jsonschema:"description=The host to use for the SMTP authentication."`
40+
// TODO: Make the port an integer
41+
Port string `json:"port" jsonschema:"description=The port to use for the SMTP authentication"`
42+
}
43+
44+
type registrationState struct {
45+
To string `json:"to"`
46+
From string `json:"from"`
47+
User string `json:"user"`
48+
Host string `json:"host"`
49+
Port string `json:"port"`
50+
}
51+
52+
type attachmentRequest struct {
53+
CC string `json:"cc,omitempty" jsonschema:"format=email,description=The email address of the carbon copy recipient."`
54+
}
55+
56+
type attachmentState struct {
57+
CC string `json:"cc"`
58+
}
59+
60+
func New(l log.Logger) (sdk.FanOut, error) {
61+
base, err := sdk.NewFanOut(
62+
&sdk.NewParams{
63+
ID: "smtp",
64+
Version: "0.1",
65+
Logger: l,
66+
InputSchema: &sdk.InputSchema{
67+
Registration: registrationRequest{},
68+
Attachment: attachmentRequest{},
69+
},
70+
},
71+
sdk.WithEnvelope(),
72+
)
73+
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
return &Integration{base}, nil
79+
}
80+
81+
// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
82+
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
83+
i.Logger.Info("registration requested")
84+
85+
// Unmarshal the request
86+
var request *registrationRequest
87+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
88+
return nil, fmt.Errorf("invalid registration request: %w", err)
89+
}
90+
91+
to, from, user, password, host, port := request.To, request.From, request.User, request.Password, request.Host, request.Port
92+
93+
// validate and notify
94+
subject := "[chainloop] New SMTP integration added!"
95+
tpl := `
96+
We successfully registered a new SMTP integration in your Chainloop organization.
97+
98+
Extension: %s version: %s
99+
- Host: %s
100+
- Port: %s
101+
- User: %s
102+
- From: %s
103+
- To: %s
104+
`
105+
body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to)
106+
err := sendEmail(host, port, user, password, from, to, "", subject, body)
107+
if err != nil {
108+
return nil, fmt.Errorf("sending an email: %w", err)
109+
}
110+
111+
response := &sdk.RegistrationResponse{}
112+
rawConfig, err := sdk.ToConfig(&registrationState{To: to, From: from, User: user, Host: host, Port: port})
113+
if err != nil {
114+
return nil, fmt.Errorf("marshalling configuration: %w", err)
115+
}
116+
response.Configuration = rawConfig
117+
response.Credentials = &sdk.Credentials{Password: password}
118+
119+
return response, nil
120+
}
121+
122+
// Attachment is executed when to attach a registered instance of this integration to a specific workflow
123+
func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
124+
i.Logger.Info("attachment requested")
125+
126+
// Parse the request that has already been validated against the input schema
127+
var request *attachmentRequest
128+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
129+
return nil, fmt.Errorf("invalid attachment request: %w", err)
130+
}
131+
132+
response := &sdk.AttachmentResponse{}
133+
rawConfig, err := sdk.ToConfig(&attachmentState{CC: request.CC})
134+
if err != nil {
135+
return nil, fmt.Errorf("marshalling configuration: %w", err)
136+
}
137+
response.Configuration = rawConfig
138+
139+
return response, nil
140+
}
141+
142+
// Send the SBOM to the configured Dependency Track instance
143+
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error {
144+
i.Logger.Info("execution requested")
145+
146+
if err := validateExecuteRequest(req); err != nil {
147+
return fmt.Errorf("running validation for workflow id %s: %w", req.WorkflowID, err)
148+
}
149+
150+
var rc *registrationState
151+
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil {
152+
return errors.New("invalid registration configuration")
153+
}
154+
155+
var ac *attachmentState
156+
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &ac); err != nil {
157+
return errors.New("invalid attachment configuration")
158+
}
159+
160+
// get the attestation
161+
decodedPayload, err := req.Input.DSSEnvelope.DecodeB64Payload()
162+
if err != nil {
163+
return err
164+
}
165+
statement := &in_toto.Statement{}
166+
if err := json.Unmarshal(decodedPayload, statement); err != nil {
167+
return fmt.Errorf("un-marshaling predicate: %w", err)
168+
}
169+
jsonBytes, err := json.MarshalIndent(statement, "", " ")
170+
if err != nil {
171+
return fmt.Errorf("error marshaling JSON: %w", err)
172+
}
173+
174+
// send the email
175+
to, from, user, password, host, port := rc.To, rc.From, rc.User, req.RegistrationInfo.Credentials.Password, rc.Host, rc.Port
176+
subject := "[chainloop] New workflow run finished successfully!"
177+
tpl := `A new workflow run finished successfully!
178+
179+
# Workflow: %s
180+
181+
# in-toto statement:
182+
%s
183+
184+
This email has been delivered via integration %s version %s.
185+
`
186+
body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version)
187+
err = sendEmail(host, port, user, password, from, to, ac.CC, subject, body)
188+
if err != nil {
189+
return fmt.Errorf("sending an email: %w", err)
190+
}
191+
192+
return nil
193+
}
194+
195+
func validateExecuteRequest(req *sdk.ExecutionRequest) error {
196+
if req == nil || req.Input == nil || req.Input.DSSEnvelope == nil {
197+
return errors.New("invalid input")
198+
}
199+
200+
if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil {
201+
return errors.New("missing registration configuration")
202+
}
203+
204+
if req.RegistrationInfo.Credentials == nil {
205+
return errors.New("missing credentials")
206+
}
207+
208+
if req.AttachmentInfo == nil || req.AttachmentInfo.Configuration == nil {
209+
return errors.New("missing attachment configuration")
210+
}
211+
212+
return nil
213+
}
214+
215+
func sendEmail(host string, port string, user, password, from, to, cc, subject, body string) error {
216+
message := "From: " + from + "\n" +
217+
"To: " + to + "\n" +
218+
"CC: " + cc + "\n" +
219+
"Subject: " + subject + "\n\n" +
220+
body
221+
222+
auth := nsmtp.PlainAuth("", user, password, host)
223+
err := nsmtp.SendMail(host+":"+port, auth, from, []string{to}, []byte(message))
224+
if err != nil {
225+
return fmt.Errorf("error sending email: %w", err)
226+
}
227+
228+
return nil
229+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package smtp
17+
18+
import (
19+
"encoding/json"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func TestValidateRegistrationInput(t *testing.T) {
27+
testCases := []struct {
28+
name string
29+
input map[string]interface{}
30+
errMsg string
31+
}{
32+
{
33+
name: "missing properties",
34+
input: map[string]interface{}{},
35+
errMsg: "missing properties: 'to', 'from', 'user'",
36+
},
37+
{
38+
name: "valid request",
39+
input: map[string]interface{}{"from": "test@example.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"},
40+
},
41+
{
42+
name: "invalid email",
43+
input: map[string]interface{}{"from": "testexample.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"},
44+
errMsg: "is not valid 'email'",
45+
},
46+
}
47+
48+
integration, err := New(nil)
49+
require.NoError(t, err)
50+
51+
for _, tc := range testCases {
52+
t.Run(tc.name, func(t *testing.T) {
53+
payload, err := json.Marshal(tc.input)
54+
require.NoError(t, err)
55+
err = integration.ValidateRegistrationRequest(payload)
56+
if tc.errMsg != "" {
57+
assert.ErrorContains(t, err, tc.errMsg)
58+
} else {
59+
assert.NoError(t, err)
60+
}
61+
})
62+
}
63+
}
64+
65+
func TestValidateAttachmentInput(t *testing.T) {
66+
testCases := []struct {
67+
name string
68+
input map[string]interface{}
69+
errMsg string
70+
}{
71+
{
72+
name: "valid with no optional cc",
73+
input: map[string]interface{}{},
74+
},
75+
{
76+
name: "valid cc format",
77+
input: map[string]interface{}{"cc": "test@example.com"},
78+
},
79+
{
80+
name: "invalid cc format",
81+
input: map[string]interface{}{"cc": "testexample.com"},
82+
errMsg: "is not valid 'email'",
83+
},
84+
}
85+
86+
integration, err := New(nil)
87+
require.NoError(t, err)
88+
89+
for _, tc := range testCases {
90+
t.Run(tc.name, func(t *testing.T) {
91+
payload, err := json.Marshal(tc.input)
92+
require.NoError(t, err)
93+
err = integration.ValidateAttachmentRequest(payload)
94+
if tc.errMsg != "" {
95+
assert.ErrorContains(t, err, tc.errMsg)
96+
} else {
97+
assert.NoError(t, err)
98+
}
99+
})
100+
}
101+
}
102+
103+
func TestNewIntegration(t *testing.T) {
104+
_, err := New(nil)
105+
assert.NoError(t, err)
106+
}

app/controlplane/extensions/extensions.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package extensions
1717

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

3739
// Initialize and load the extensions

0 commit comments

Comments
 (0)