Skip to content

Commit 6a0799d

Browse files
committed
feat: Added SMTP extension, refs #158
Signed-off-by: Daniel Liszka <daniel@chainloop.dev>
1 parent 2654568 commit 6a0799d

File tree

3 files changed

+252
-0
lines changed

3 files changed

+252
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# SMTP Fan-out Extension
2+
3+
With this extension, you can send an email for every workflow run and attestation, ensuring better communication.
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 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+
Starting now, every time a workflow run occurs, an email notification will be sent containing the details of the run and attestation.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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:"required,format=email"`
36+
From string `json:"from" jsonschema:"required,format=email"`
37+
User string `json:"user" jsonschema:"required,minLength=1"`
38+
Password string `json:"password" jsonschema:"required"`
39+
Host string `json:"host" jsonschema:"required,format="`
40+
Port string `json:"port" jsonschema:"required"`
41+
}
42+
43+
type registrationState struct {
44+
To string `json:"to"`
45+
From string `json:"from"`
46+
User string `json:"user"`
47+
Host string `json:"host"`
48+
Port string `json:"port"`
49+
}
50+
51+
type attachmentRequest struct {
52+
Cc string `json:"cc" jsonschema:"format=email"`
53+
}
54+
55+
type attachmentState struct {
56+
Cc string `json:"cc"`
57+
}
58+
59+
func New(l log.Logger) (sdk.FanOut, error) {
60+
base, err := sdk.NewFanOut(
61+
&sdk.NewParams{
62+
ID: "smtp",
63+
Version: "0.1",
64+
Logger: l,
65+
InputSchema: &sdk.InputSchema{
66+
Registration: registrationRequest{},
67+
Attachment: attachmentRequest{},
68+
},
69+
},
70+
sdk.WithEnvelope(),
71+
)
72+
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
return &Integration{base}, nil
78+
}
79+
80+
// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
81+
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
82+
i.Logger.Info("registration requested")
83+
84+
// Unmarshal the request
85+
var request *registrationRequest
86+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
87+
return nil, fmt.Errorf("invalid registration request: %w", err)
88+
}
89+
90+
response := &sdk.RegistrationResponse{}
91+
92+
to, from, user, password, host, port := request.To, request.From, request.User, request.Password, request.Host, request.Port
93+
rawConfig, err := sdk.ToConfig(&registrationState{To: to, From: from, User: user, Host: host, Port: port})
94+
if err != nil {
95+
return nil, fmt.Errorf("marshalling configuration: %w", err)
96+
}
97+
response.Configuration = rawConfig
98+
response.Credentials = &sdk.Credentials{Password: password}
99+
100+
// validate and notify
101+
subject := "[chainloop] New SMTP integration added!"
102+
tpl := `
103+
We successfully registered a new SMTP integration in your Chainloop organization.
104+
105+
Extension: %s version: %s
106+
- Host: %s
107+
- Port: %s
108+
- User: %s
109+
- From: %s
110+
- To: %s
111+
`
112+
body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to)
113+
sendEmail(host, port, user, password, from, to, "", subject, body)
114+
115+
return response, nil
116+
}
117+
118+
// Attachment is executed when to attach a registered instance of this integration to a specific workflow
119+
func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
120+
i.Logger.Info("attachment requested")
121+
122+
// Parse the request that has already been validated against the input schema
123+
var request *attachmentRequest
124+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
125+
return nil, fmt.Errorf("invalid attachment request: %w", err)
126+
}
127+
128+
var rc *registrationState
129+
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil {
130+
return nil, errors.New("invalid registration configuration")
131+
}
132+
133+
response := &sdk.AttachmentResponse{}
134+
rawConfig, err := sdk.ToConfig(&attachmentState{Cc: request.Cc})
135+
if err != nil {
136+
return nil, fmt.Errorf("marshalling configuration: %w", err)
137+
}
138+
response.Configuration = rawConfig
139+
140+
return response, nil
141+
}
142+
143+
// Send the SBOM to the configured Dependency Track instance
144+
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error {
145+
i.Logger.Info("execution requested")
146+
147+
if err := validateExecuteRequest(req); err != nil {
148+
return fmt.Errorf("running validation for workflow id %s: %w", req.WorkflowID, err)
149+
}
150+
151+
var rc *registrationState
152+
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil {
153+
return errors.New("invalid registration configuration")
154+
}
155+
156+
var ac *attachmentState
157+
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &ac); err != nil {
158+
return errors.New("invalid attachment configuration")
159+
}
160+
161+
// get the attestation
162+
decodedPayload, err := req.Input.DSSEnvelope.DecodeB64Payload()
163+
if err != nil {
164+
return err
165+
}
166+
statement := &in_toto.Statement{}
167+
if err := json.Unmarshal(decodedPayload, statement); err != nil {
168+
return fmt.Errorf("un-marshaling predicate: %w", err)
169+
}
170+
jsonBytes, err := json.MarshalIndent(statement, "", " ")
171+
i.Logger.Info("statement", string(jsonBytes))
172+
if err != nil {
173+
fmt.Println("Error marshaling JSON:", err)
174+
return err
175+
}
176+
177+
// send the email
178+
to, from, user, password, host, port := rc.To, rc.From, rc.User, req.RegistrationInfo.Credentials.Password, rc.Host, rc.Port
179+
subject := "[chainloop] New workflow run finished successfully!"
180+
tpl := `A new workflow run finished successfully!
181+
182+
# Workflow: %s
183+
184+
# in-toto statement:
185+
%s
186+
187+
This email has been delivered via integration %s version %s.
188+
`
189+
body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version)
190+
sendEmail(host, port, user, password, from, to, ac.Cc, subject, body)
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+
fmt.Println("Error sending email1:", err)
226+
return err
227+
}
228+
229+
return nil
230+
}

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)