Skip to content

Commit 625b153

Browse files
authored
feat(dependency-track): support parent ID for autocreate projects (#534) (#543)
Signed-off-by: Seb Dangerfield <[email protected]>
1 parent 1813a6d commit 625b153

File tree

6 files changed

+116
-22
lines changed

6 files changed

+116
-22
lines changed

app/controlplane/plugins/core/dependency-track/v1/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ See https://docs.chainloop.dev/guides/dependency-track/
4545

4646
|Field|Type|Required|Description|
4747
|---|---|---|---|
48+
|parentID|string|no|ID of parent project to create a new project under|
4849
|projectID|string|no|The ID of the existing project to send the SBOMs to|
4950
|projectName|string|no|The name of the project to create and send the SBOMs to|
5051

@@ -76,9 +77,19 @@ See https://docs.chainloop.dev/guides/dependency-track/
7677
"type": "string",
7778
"minLength": 1,
7879
"description": "The name of the project to create and send the SBOMs to"
80+
},
81+
"parentID": {
82+
"type": "string",
83+
"minLength": 1,
84+
"description": "ID of parent project to create a new project under"
7985
}
8086
},
8187
"additionalProperties": false,
82-
"type": "object"
88+
"type": "object",
89+
"dependentRequired": {
90+
"parentID": [
91+
"projectName"
92+
]
93+
}
8394
}
8495
```

app/controlplane/plugins/core/dependency-track/v1/client/sbom.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type SBOMUploader struct {
4444
sbom io.Reader
4545
// Either use a projectID or create a new one by name
4646
projectID, projectName string
47+
// Optional parentID to use with projectName
48+
parentID string
4749
}
4850

4951
func newBase(host, apiKey string) (*base, error) {
@@ -69,7 +71,7 @@ func NewIntegration(host, apiKey string, checkAutoCreate bool) (*Integration, er
6971
return &Integration{base: b, checkAutoCreate: checkAutoCreate}, nil
7072
}
7173

72-
func NewSBOMUploader(host, apiKey string, sbom io.Reader, projectID, projectName string) (*SBOMUploader, error) {
74+
func NewSBOMUploader(host, apiKey string, sbom io.Reader, projectID, projectName string, parentID string) (*SBOMUploader, error) {
7375
b, err := newBase(host, apiKey)
7476
if err != nil {
7577
return nil, err
@@ -79,7 +81,11 @@ func NewSBOMUploader(host, apiKey string, sbom io.Reader, projectID, projectName
7981
return nil, errors.New("either existing project ID or new name is required")
8082
}
8183

82-
return &SBOMUploader{b, sbom, projectID, projectName}, nil
84+
if parentID != "" && projectName == "" {
85+
return nil, errors.New("project name is required with parent ID")
86+
}
87+
88+
return &SBOMUploader{b, sbom, projectID, projectName, parentID}, nil
8389
}
8490

8591
const bomUploadPermission = "BOM_UPLOAD"
@@ -118,26 +124,33 @@ func (d *SBOMUploader) Validate(ctx context.Context) error {
118124
return fmt.Errorf("validating the permissions: %w", err)
119125
}
120126

121-
if d.projectID == "" {
127+
if d.projectID == "" && d.parentID == "" {
122128
return nil
123129
}
124130

125-
// Check if the project exists
131+
var existingProjectID string
132+
if d.projectID != "" {
133+
existingProjectID = d.projectID
134+
} else {
135+
existingProjectID = d.parentID
136+
}
137+
138+
// Check if the project or parent project exists
126139
var projectFound bool
127140
projects, err := listProjects(d.host, d.apiKey)
128141
if err != nil {
129142
return fmt.Errorf("checking that the project exists: %w", err)
130143
}
131144

132145
for _, p := range projects {
133-
if p.ID == d.projectID {
146+
if p.ID == existingProjectID {
134147
projectFound = true
135148
break
136149
}
137150
}
138151

139152
if !projectFound {
140-
return fmt.Errorf("project with ID %q not found", d.projectID)
153+
return fmt.Errorf("project with ID %q not found", existingProjectID)
141154
}
142155

143156
return nil
@@ -153,6 +166,10 @@ func (d *SBOMUploader) Do(_ context.Context) error {
153166
if autocreate {
154167
values["autoCreate"] = strings.NewReader("true")
155168
values["projectName"] = strings.NewReader(d.projectName)
169+
170+
if d.parentID != "" {
171+
values["parentUUID"] = strings.NewReader(d.parentID)
172+
}
156173
} else {
157174
values["project"] = strings.NewReader(d.projectID)
158175
}

app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,30 @@ const hostname = "http://example.com"
2929
func TestNewSBOMUploader(t *testing.T) {
3030
const projectID = "existing-project-id"
3131
const projectName = "new project name"
32+
const parentID = "parent-existing-project-id"
3233
var sbomReader = bytes.NewBuffer(nil)
3334

3435
tests := []struct {
35-
hostname, apiKey, projectID, projectName string
36-
sbom io.Reader
37-
wantError bool
36+
hostname, apiKey, projectID, projectName, parentID string
37+
sbom io.Reader
38+
wantError bool
3839
}{
3940
// no api key
40-
{hostname, "", projectID, projectName, sbomReader, true},
41+
{hostname, "", projectID, projectName, parentID, sbomReader, true},
4142
// invalid hostname
42-
{"invalid-hostname", "apikey", projectID, projectName, sbomReader, true},
43+
{"invalid-hostname", "apikey", projectID, projectName, parentID, sbomReader, true},
4344
// both projectID and name
44-
{hostname, "apikey", projectID, projectName, sbomReader, true},
45-
{hostname, "apikey", projectID, "", sbomReader, false},
46-
{hostname, "apikey", "", projectName, sbomReader, false},
45+
{hostname, "apikey", projectID, projectName, "", sbomReader, true},
46+
{hostname, "apikey", projectID, "", "", sbomReader, false},
47+
{hostname, "apikey", "", projectName, "", sbomReader, false},
48+
// parent ID
49+
{hostname, "apikey", projectID, "", parentID, sbomReader, true},
50+
{hostname, "apikey", "", projectName, parentID, sbomReader, false},
4751
}
4852

4953
assert := assert.New(t)
5054
for _, tc := range tests {
51-
got, err := NewSBOMUploader(tc.hostname, tc.apiKey, tc.sbom, tc.projectID, tc.projectName)
55+
got, err := NewSBOMUploader(tc.hostname, tc.apiKey, tc.sbom, tc.projectID, tc.projectName, tc.parentID)
5256
if tc.wantError {
5357
assert.Error(err)
5458
continue
@@ -63,6 +67,7 @@ func TestNewSBOMUploader(t *testing.T) {
6367
},
6468
tc.sbom,
6569
tc.projectID, tc.projectName,
70+
tc.parentID,
6671
}, got)
6772
}
6873
}

app/controlplane/plugins/core/dependency-track/v1/extension.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/dependency-track/v1/client"
2828
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1"
2929
"github.com/go-kratos/kratos/v2/log"
30+
"github.com/invopop/jsonschema"
3031
)
3132

3233
type DependencyTrack struct {
@@ -46,6 +47,18 @@ type attachmentRequest struct {
4647
// Either one or the other
4748
ProjectID string `json:"projectID,omitempty" jsonschema:"oneof_required=projectID,minLength=1,description=The ID of the existing project to send the SBOMs to"`
4849
ProjectName string `json:"projectName,omitempty" jsonschema:"oneof_required=projectName,minLength=1,description=The name of the project to create and send the SBOMs to"`
50+
51+
ParentID string `json:"parentID,omitempty" jsonschema:"minLength=1,description=ID of parent project to create a new project under"`
52+
}
53+
54+
// Enforces the requirement that parentID requires the presence of projectName
55+
// invopop/jsonschema doesn't appear to support dependentRequired through reflection
56+
func (x attachmentRequest) JSONSchemaExtend(schema *jsonschema.Schema) {
57+
schema.DependentRequired = map[string][]string{
58+
"parentID": {
59+
"projectName",
60+
},
61+
}
4962
}
5063

5164
// Internal state for both registration and attachment
@@ -57,6 +70,7 @@ type registrationConfig struct {
5770
type attachmentConfig struct {
5871
ProjectID string `json:"projectId"`
5972
ProjectName string `json:"projectName"`
73+
ParentID string `json:"parentId"`
6074
}
6175

6276
const description = "Send CycloneDX SBOMs to your Dependency-Track instance"
@@ -65,7 +79,7 @@ func New(l log.Logger) (sdk.FanOut, error) {
6579
base, err := sdk.NewFanOut(
6680
&sdk.NewParams{
6781
ID: "dependency-track",
68-
Version: "1.3",
82+
Version: "1.4",
6983
Description: description,
7084
Logger: l,
7185
InputSchema: &sdk.InputSchema{
@@ -134,10 +148,10 @@ func (i *DependencyTrack) Attach(ctx context.Context, req *sdk.AttachmentRequest
134148
return nil, fmt.Errorf("invalid attachment configuration: %w", err)
135149
}
136150

137-
i.Logger.Infow("msg", "attachment OK", "projectID", request.ProjectID, "projectName", request.ProjectName)
151+
i.Logger.Infow("msg", "attachment OK", "projectID", request.ProjectID, "projectName", request.ProjectName, "parentID", request.ParentID)
138152

139153
// We want to store the project configuration
140-
rawConfig, err := sdk.ToConfig(&attachmentConfig{ProjectID: request.ProjectID, ProjectName: request.ProjectName})
154+
rawConfig, err := sdk.ToConfig(&attachmentConfig{ProjectID: request.ProjectID, ProjectName: request.ProjectName, ParentID: request.ParentID})
141155
if err != nil {
142156
return nil, fmt.Errorf("marshalling configuration: %w", err)
143157
}
@@ -204,7 +218,8 @@ func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.Execute
204218
req.RegistrationInfo.Credentials.Password,
205219
bytes.NewReader(sbom.Content),
206220
attachmentConfig.ProjectID,
207-
projectName)
221+
projectName,
222+
attachmentConfig.ParentID)
208223
if err != nil {
209224
return fmt.Errorf("creating uploader: %w", err)
210225
}
@@ -281,7 +296,7 @@ func validateAttachment(ctx context.Context, rc *registrationConfig, ac *attachm
281296
}
282297

283298
// Instantiate an actual client to see if it would work with the current configuration
284-
d, err := client.NewSBOMUploader(rc.Domain, credentials.Password, nil, ac.ProjectID, ac.ProjectName)
299+
d, err := client.NewSBOMUploader(rc.Domain, credentials.Password, nil, ac.ProjectID, ac.ProjectName, ac.ParentID)
285300
if err != nil {
286301
return fmt.Errorf("creating uploader: %w", err)
287302
}
@@ -313,6 +328,10 @@ func validateAttachmentConfiguration(rc *registrationConfig, ac *attachmentReque
313328
return errors.New("project id or name must be provided")
314329
}
315330

331+
if ac.ParentID != "" && ac.ProjectName == "" {
332+
return errors.New("project name must be provided to work with parent id")
333+
}
334+
316335
return nil
317336
}
318337

app/controlplane/plugins/core/dependency-track/v1/extension_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ func TestValidateAttachmentInput(t *testing.T) {
193193
input: map[string]interface{}{"projectID": "project-id", "projectName": "project-name"},
194194
errMsg: "valid against schemas at indexes 0 and 1",
195195
},
196+
{
197+
name: "valid request, project name and parent ID",
198+
input: map[string]interface{}{"projectName": "project-name", "parentID": "parent-id"},
199+
},
200+
{
201+
name: "invalid with project ID and parent ID",
202+
input: map[string]interface{}{"projectID": "project-id", "parentID": "parent-id"},
203+
errMsg: "property 'projectName' is required, if 'parentID' property exists",
204+
},
196205
}
197206

198207
integration, err := New(nil)
@@ -293,3 +302,36 @@ func TestValidateExecuteOpts(t *testing.T) {
293302
})
294303
}
295304
}
305+
306+
func TestValidateAttachmentConfiguration(t *testing.T) {
307+
testCases := []struct {
308+
allowAutoCreate bool
309+
projectID, projectName, parentID string
310+
errMsg string
311+
}{
312+
{false, "project-id", "", "", ""},
313+
{true, "", "project-name", "", ""},
314+
{true, "", "project-name", "parent-id", ""},
315+
{false, "", "project-name", "", "auto creation of projects is not supported in this integration"},
316+
{false, "", "", "", "project id or name must be provided"},
317+
{false, "project-id", "", "parent-id", "project name must be provided to work with parent id"},
318+
}
319+
320+
for _, tc := range testCases {
321+
rc := &registrationConfig{
322+
Domain: "http://dtrack.localhost",
323+
AllowAutoCreate: tc.allowAutoCreate,
324+
}
325+
ac := &attachmentRequest{
326+
ProjectID: tc.projectID,
327+
ProjectName: tc.projectName,
328+
ParentID: tc.parentID,
329+
}
330+
err := validateAttachmentConfiguration(rc, ac)
331+
if tc.errMsg != "" {
332+
assert.ErrorContains(t, err, tc.errMsg)
333+
} else {
334+
assert.Nil(t, err)
335+
}
336+
}
337+
}

docs/integrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Below you can find the list of currently available integrations. If you can't fi
1010

1111
| ID | Version | Description | Material Requirement |
1212
| --- | --- | --- | --- |
13-
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.3 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
13+
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.4 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
1414
| [discord-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/discord-webhook/v1/README.md) | 1.1 | Send attestations to Discord | |
1515
| [guac](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/guac/v1/README.md) | 1.0 | Export Attestation and SBOMs metadata to a blob storage backend so guacsec/guac can consume it | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON |
1616
| [slack-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/slack-webhook/v1/README.md) | 1.0 | Send attestations to Slack | |

0 commit comments

Comments
 (0)