diff --git a/README.md b/README.md
index b141ea46..38039b41 100644
--- a/README.md
+++ b/README.md
@@ -46,10 +46,22 @@ resource "cosign_sign" "example" {
resource "cosign_attest" "example" {
image = cosign_sign.example.signed_ref
- predicate_type = "https://example.com/my/predicate/type"
- predicate = jsonencode({
- // Your claim here!
- })
+
+ predicates {
+ type = "https://example.com/my/predicate/type"
+ json = jsonencode({
+ // Your claim here!
+ })
+ }
+
+ // Inlining e.g. huge SBOMs will slow down terraform a lot, so reference a file.
+ predicates {
+ type = "https://example.com/my/predicate/too-big-for-terraform.tfstate"
+ file = {
+ path = "/tmp/giant-file.json"
+ sha256 = "74af7407b59f9021f76a6f9ee66149c5df1ef6442617a805a7860ce18074158d"
+ }
+ }
}
# Reference cosign_attest.example.attested_ref to ensure we wait for all of the
diff --git a/docs/resources/attest.md b/docs/resources/attest.md
index 69ef84c6..d4d25229 100644
--- a/docs/resources/attest.md
+++ b/docs/resources/attest.md
@@ -18,13 +18,14 @@ This attests the provided image digest with cosign.
### Required
- `image` (String) The digest of the container image to attest.
-- `predicate_type` (String) The in-toto predicate type of the claim being attested.
### Optional
- `fulcio_url` (String) Address of sigstore PKI server (default https://fulcio.sigstore.dev).
-- `predicate` (String) The JSON body of the in-toto predicate's claim.
-- `predicate_file` (Block List) The path and sha256 hex of the predicate to attest. (see [below for nested schema](#nestedblock--predicate_file))
+- `predicate` (String, Deprecated) The JSON body of the in-toto predicate's claim.
+- `predicate_file` (Block List, Deprecated) The path and sha256 hex of the predicate to attest. (see [below for nested schema](#nestedblock--predicate_file))
+- `predicate_type` (String, Deprecated) The in-toto predicate type of the claim being attested.
+- `predicates` (Block List) The path and sha256 hex of the predicate to attest. (see [below for nested schema](#nestedblock--predicates))
- `rekor_url` (String) Address of rekor transparency log server (default https://rekor.sigstore.dev).
### Read-Only
@@ -41,3 +42,24 @@ Optional:
- `sha256` (String) The sha256 hex hash of the predicate body.
+
+### Nested Schema for `predicates`
+
+Required:
+
+- `type` (String) The in-toto predicate type of the claim being attested.
+
+Optional:
+
+- `file` (Block List) The path and sha256 hex of the predicate to attest. (see [below for nested schema](#nestedblock--predicates--file))
+- `json` (String) The JSON body of the in-toto predicate's claim.
+
+
+### Nested Schema for `predicates.file`
+
+Optional:
+
+- `path` (String) The path to a file containing the predicate to attest.
+- `sha256` (String) The sha256 hex hash of the predicate body.
+
+
diff --git a/internal/provider/resource_attest.go b/internal/provider/resource_attest.go
index cfc8a52c..85aa6047 100644
--- a/internal/provider/resource_attest.go
+++ b/internal/provider/resource_attest.go
@@ -9,10 +9,12 @@ import (
"os"
"github.com/chainguard-dev/terraform-provider-cosign/internal/secant"
+ stypes "github.com/chainguard-dev/terraform-provider-cosign/internal/secant/types"
"github.com/chainguard-dev/terraform-provider-oci/pkg/validators"
"github.com/google/go-containerregistry/pkg/name"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -42,13 +44,23 @@ type AttestResource struct {
popts *ProviderOpts
}
+type PredicateObject struct {
+ PredicateType types.String `tfsdk:"type"`
+ Predicate types.String `tfsdk:"json"`
+ PredicateFile types.List `tfsdk:"file"`
+}
+
type AttestResourceModel struct {
- Id types.String `tfsdk:"id"`
- Image types.String `tfsdk:"image"`
+ Id types.String `tfsdk:"id"`
+ Image types.String `tfsdk:"image"`
+
+ // PredicateObject, left for backward compat.
PredicateType types.String `tfsdk:"predicate_type"`
Predicate types.String `tfsdk:"predicate"`
PredicateFile types.List `tfsdk:"predicate_file"`
+ Predicates types.List `tfsdk:"predicates"`
+
AttestedRef types.String `tfsdk:"attested_ref"`
FulcioURL types.String `tfsdk:"fulcio_url"`
RekorURL types.String `tfsdk:"rekor_url"`
@@ -59,11 +71,6 @@ func (r *AttestResource) Metadata(ctx context.Context, req resource.MetadataRequ
}
func (r *AttestResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
- singlePredicate := stringvalidator.ExactlyOneOf(
- path.MatchRoot("predicate"),
- path.MatchRoot("predicate_file").AtListIndex(0).AtName("sha256"),
- )
-
resp.Schema = schema.Schema{
MarkdownDescription: "This attests the provided image digest with cosign.",
Attributes: map[string]schema.Attribute{
@@ -85,8 +92,9 @@ func (r *AttestResource) Schema(ctx context.Context, req resource.SchemaRequest,
},
"predicate_type": schema.StringAttribute{
MarkdownDescription: "The in-toto predicate type of the claim being attested.",
- Optional: false,
- Required: true,
+ Optional: true,
+ Required: false,
+ DeprecationMessage: "Use predicates instead.",
Validators: []validator.String{validators.URLValidator{}},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
@@ -96,9 +104,9 @@ func (r *AttestResource) Schema(ctx context.Context, req resource.SchemaRequest,
MarkdownDescription: "The JSON body of the in-toto predicate's claim.",
Optional: true,
Required: false,
+ DeprecationMessage: "Use predicates instead.",
Validators: []validator.String{
validators.JSONValidator{},
- singlePredicate,
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
@@ -127,6 +135,7 @@ func (r *AttestResource) Schema(ctx context.Context, req resource.SchemaRequest,
"predicate_file": schema.ListNestedBlock{
MarkdownDescription: "The path and sha256 hex of the predicate to attest.",
Validators: []validator.List{listvalidator.SizeBetween(1, 1)},
+ DeprecationMessage: "Use predicates instead.",
NestedObject: schema.NestedBlockObject{
Attributes: map[string]schema.Attribute{
"sha256": schema.StringAttribute{
@@ -134,7 +143,6 @@ func (r *AttestResource) Schema(ctx context.Context, req resource.SchemaRequest,
Optional: true,
Required: false,
Validators: []validator.String{
- singlePredicate,
stringvalidator.AlsoRequires(path.MatchRoot("predicate_file").AtListIndex(0).AtName("path")),
},
PlanModifiers: []planmodifier.String{
@@ -152,6 +160,71 @@ func (r *AttestResource) Schema(ctx context.Context, req resource.SchemaRequest,
},
},
},
+ "predicates": schema.ListNestedBlock{
+ MarkdownDescription: "The path and sha256 hex of the predicate to attest.",
+ Validators: []validator.List{listvalidator.SizeAtLeast(1)},
+ NestedObject: schema.NestedBlockObject{
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{
+ MarkdownDescription: "The in-toto predicate type of the claim being attested.",
+ Optional: false,
+ Required: true,
+ Validators: []validator.String{validators.URLValidator{}},
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "json": schema.StringAttribute{
+ MarkdownDescription: "The JSON body of the in-toto predicate's claim.",
+ Optional: true,
+ Required: false,
+ Validators: []validator.String{
+ validators.JSONValidator{},
+ stringvalidator.ExactlyOneOf(
+ path.MatchRelative(),
+ path.MatchRelative().AtParent().AtName("file").AtListIndex(0).AtName("sha256"),
+ ),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ },
+ Blocks: map[string]schema.Block{
+ "file": schema.ListNestedBlock{
+ MarkdownDescription: "The path and sha256 hex of the predicate to attest.",
+ Validators: []validator.List{listvalidator.SizeBetween(1, 1)},
+ NestedObject: schema.NestedBlockObject{
+ Attributes: map[string]schema.Attribute{
+ "sha256": schema.StringAttribute{
+ MarkdownDescription: "The sha256 hex hash of the predicate body.",
+ Optional: true,
+ Required: false,
+ Validators: []validator.String{
+ stringvalidator.ExactlyOneOf(
+ path.MatchRelative(),
+ path.MatchRelative().AtParent().AtParent().AtParent().AtName("json"),
+ ),
+ stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("path")),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "path": schema.StringAttribute{
+ MarkdownDescription: "The path to a file containing the predicate to attest.",
+ Optional: true,
+ Required: false,
+ Validators: []validator.String{
+ stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("sha256")),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
},
}
}
@@ -170,8 +243,31 @@ func (r *AttestResource) Configure(ctx context.Context, req resource.ConfigureRe
r.popts = popts
}
-func (r *AttestResource) doAttest(ctx context.Context, data *AttestResourceModel) (string, error, error) {
- digest, err := name.NewDigest(data.Image.ValueString())
+func toPredObjects(ctx context.Context, arm *AttestResourceModel) ([]PredicateObject, diag.Diagnostics) {
+ preds := []PredicateObject{}
+ diags := arm.Predicates.ElementsAs(ctx, &preds, false)
+ if obj := toPredObject(arm); obj != nil {
+ preds = append(preds, *obj)
+ }
+
+ return preds, diags
+}
+
+// TODO: Remove this when we deprecate top-level predicate.
+func toPredObject(data *AttestResourceModel) *PredicateObject {
+ if data.Predicate.ValueString() != "" || len(data.PredicateFile.Elements()) > 0 {
+ return &PredicateObject{
+ PredicateType: data.PredicateType,
+ PredicateFile: data.PredicateFile,
+ Predicate: data.Predicate,
+ }
+ }
+
+ return nil
+}
+
+func (r *AttestResource) doAttest(ctx context.Context, arm *AttestResourceModel, preds []PredicateObject) (string, error, error) {
+ digest, err := name.NewDigest(arm.Image.ValueString())
if err != nil {
return "", nil, errors.New("unable to parse image digest")
}
@@ -183,58 +279,64 @@ func (r *AttestResource) doAttest(ctx context.Context, data *AttestResourceModel
return digest.String(), errors.New("no ambient credentials are available to attest with, skipping attesting"), nil
}
- // Write the attestation to a temporary file.
- var path string
- switch {
- // Write the predicate to a file to pass to attest.
- case data.Predicate.ValueString() != "":
- file, err := os.CreateTemp("", "")
- if err != nil {
- return "", nil, err
- }
- defer os.Remove(file.Name())
- if _, err := file.WriteString(data.Predicate.ValueString()); err != nil {
- return "", nil, err
+ statements := []*stypes.Statement{}
+
+ for _, data := range preds {
+ // Write the attestation to a temporary file.
+ var path string
+ switch {
+ // Write the predicate to a file to pass to attest.
+ case data.Predicate.ValueString() != "":
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ return "", nil, err
+ }
+ defer os.Remove(file.Name())
+ if _, err := file.WriteString(data.Predicate.ValueString()); err != nil {
+ return "", nil, err
+ }
+ if err := file.Close(); err != nil {
+ return "", nil, err
+ }
+ path = file.Name()
+
+ case len(data.PredicateFile.Elements()) > 0:
+ attrs := data.PredicateFile.Elements()[0].(basetypes.ObjectValue).Attributes()
+ path = attrs["path"].(basetypes.StringValue).ValueString()
+ expectedHash := attrs["sha256"].(basetypes.StringValue).ValueString()
+
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ return "", nil, err
+ }
+ rawHash := sha256.Sum256(contents)
+ if got, want := hex.EncodeToString(rawHash[:]), expectedHash; got != want {
+ return "", nil, fmt.Errorf("sha256(%q) = %s, expected %s", path, got, want)
+ }
+
+ default:
+ return "", nil, errors.New("one of predicate or predicate_file must be specified")
}
- if err := file.Close(); err != nil {
- return "", nil, err
- }
- path = file.Name()
-
- case len(data.PredicateFile.Elements()) > 0:
- attrs := data.PredicateFile.Elements()[0].(basetypes.ObjectValue).Attributes()
- path = attrs["path"].(basetypes.StringValue).ValueString()
- expectedHash := attrs["sha256"].(basetypes.StringValue).ValueString()
- contents, err := os.ReadFile(path)
+ predicate, err := os.Open(path)
if err != nil {
- return "", nil, err
- }
- rawHash := sha256.Sum256(contents)
- if got, want := hex.EncodeToString(rawHash[:]), expectedHash; got != want {
- return "", nil, fmt.Errorf("sha256(%q) = %s, expected %s", path, got, want)
+ return "", nil, fmt.Errorf("open %q: %w", path, err)
}
- default:
- return "", nil, errors.New("one of predicate or predicate_file must be specified")
- }
-
- predicate, err := os.Open(path)
- if err != nil {
- return "", nil, fmt.Errorf("open %q: %w", path, err)
- }
+ stmt, err := secant.NewStatement(digest, predicate, data.PredicateType.ValueString())
+ if err != nil {
+ return "", nil, fmt.Errorf("creating attestation statement: %w", err)
+ }
- stmt, err := secant.NewStatement(digest, predicate, data.PredicateType.ValueString())
- if err != nil {
- return "", nil, fmt.Errorf("creating attestation statement: %w", err)
+ statements = append(statements, stmt)
}
- sv, err := r.popts.signerVerifier(data.FulcioURL.ValueString())
+ sv, err := r.popts.signerVerifier(arm.FulcioURL.ValueString())
if err != nil {
return "", nil, fmt.Errorf("creating signer: %w", err)
}
- rekorClient, err := r.popts.rekorClient(data.RekorURL.ValueString())
+ rekorClient, err := r.popts.rekorClient(arm.RekorURL.ValueString())
if err != nil {
return "", nil, fmt.Errorf("creating rekor client: %w", err)
}
@@ -242,7 +344,7 @@ func (r *AttestResource) doAttest(ctx context.Context, data *AttestResourceModel
ctx, cancel := context.WithTimeout(ctx, options.DefaultTimeout)
defer cancel()
- if err := secant.Attest(ctx, stmt, sv, rekorClient, r.popts.ropts); err != nil {
+ if err := secant.Attest(ctx, statements, sv, rekorClient, r.popts.ropts); err != nil {
return "", nil, fmt.Errorf("unable to sign image: %w", err)
}
@@ -256,7 +358,13 @@ func (r *AttestResource) Create(ctx context.Context, req resource.CreateRequest,
return
}
- digest, warning, err := r.doAttest(ctx, data)
+ preds, diags := toPredObjects(ctx, data)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ digest, warning, err := r.doAttest(ctx, data, preds)
if err != nil {
resp.Diagnostics.AddError("error while attesting", err.Error())
return
@@ -298,7 +406,13 @@ func (r *AttestResource) Update(ctx context.Context, req resource.UpdateRequest,
return
}
- digest, warning, err := r.doAttest(ctx, data)
+ preds, diags := toPredObjects(ctx, data)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ digest, warning, err := r.doAttest(ctx, data, preds)
if err != nil {
resp.Diagnostics.AddError("error while attesting", err.Error())
return
diff --git a/internal/provider/resource_attest_test.go b/internal/provider/resource_attest_test.go
index 002176a9..84da13af 100644
--- a/internal/provider/resource_attest_test.go
+++ b/internal/provider/resource_attest_test.go
@@ -207,3 +207,155 @@ data "cosign_verify" "bar" {
},
})
}
+
+func TestAccResourceCosignAttests(t *testing.T) {
+ if _, ok := os.LookupEnv("ACTIONS_ID_TOKEN_REQUEST_URL"); !ok {
+ t.Skip("Unable to keylessly attest without an actions token")
+ }
+
+ repo, cleanup := ocitesting.SetupRepository(t, "test")
+ defer cleanup()
+
+ // Push two images by digest.
+ img1, err := random.Image(1024, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dig1, err := img1.Digest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ref1 := repo.Digest(dig1.String())
+ if err := remote.Write(ref1, img1); err != nil {
+ t.Fatal(err)
+ }
+
+ img2, err := random.Image(1024, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dig2, err := img2.Digest()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ref2 := repo.Digest(dig2.String())
+ if err := remote.Write(ref2, img2); err != nil {
+ t.Fatal(err)
+ }
+
+ url := "https://example.com/" + uuid.New().String()
+ url2 := "https://example.com/" + uuid.New().String()
+
+ value := uuid.New().String()
+
+ tmp, err := os.CreateTemp("", "cosign-attest-*.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ contents := fmt.Sprintf(`{"foo": %q}`, value)
+ if _, err := tmp.WriteString(contents); err != nil {
+ t.Fatal(err)
+ }
+ tmp.Close()
+ rawHash := sha256.Sum256([]byte(contents))
+ hash := hex.EncodeToString(rawHash[:])
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ // Attest and verify the first image.
+ {
+ Config: fmt.Sprintf(`
+resource "cosign_attest" "foo" {
+ image = %q
+ predicates {
+ type = %q
+ json = jsonencode({
+ foo = %q
+ })
+ }
+
+ predicates {
+ type = %q
+ file {
+ path = %q
+ sha256 = %q
+ }
+ }
+}
+
+data "cosign_verify" "bar" {
+ image = cosign_attest.foo.attested_ref
+ policy = jsonencode({
+ apiVersion = "policy.sigstore.dev/v1beta1"
+ kind = "ClusterImagePolicy"
+ metadata = {
+ name = "attested-it"
+ }
+ spec = {
+ images = [{
+ glob = %q
+ }]
+ authorities = [{
+ keyless = {
+ url = "https://fulcio.sigstore.dev"
+ identities = [{
+ issuer = "https://token.actions.githubusercontent.com"
+ subject = "https://github.com/chainguard-dev/terraform-provider-cosign/.github/workflows/test.yml@refs/heads/main"
+ }]
+ }
+ attestations = [{
+ name = "must-have-attestation"
+ predicateType = %q
+ policy = {
+ type = "cue"
+ // When we do things in this style, we can use file("foo.cue") too!
+ data = <