diff --git a/provider/challenge/flag_subdata_source.go b/provider/challenge/flag_subdata_source.go deleted file mode 100644 index c7ba890..0000000 --- a/provider/challenge/flag_subdata_source.go +++ /dev/null @@ -1,22 +0,0 @@ -package challenge - -import ( - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" -) - -func FlagSubdatasourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "content": schema.StringAttribute{ - Computed: true, - }, - "data": schema.StringAttribute{ - Computed: true, - }, - "type": schema.StringAttribute{ - Computed: true, - }, - } -} diff --git a/provider/challenge/flag_subresource.go b/provider/challenge/flag_subresource.go deleted file mode 100644 index e187006..0000000 --- a/provider/challenge/flag_subresource.go +++ /dev/null @@ -1,122 +0,0 @@ -package challenge - -import ( - "context" - "fmt" - "strconv" - - "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/validators" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -type FlagSubresourceModel struct { - ID types.String `tfsdk:"id"` - Content types.String `tfsdk:"content"` - Data types.String `tfsdk:"data"` - Type types.String `tfsdk:"type"` -} - -func FlagSubresourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "Identifier of the flag, used internally to handle the CTFd corresponding object.", - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "content": schema.StringAttribute{ - MarkdownDescription: "The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge.", - Required: true, - Sensitive: true, - }, - "data": schema.StringAttribute{ - MarkdownDescription: "The flag sensitivity information, either case_sensitive or case_insensitive", - Optional: true, - Computed: true, - // default value is "" (empty string) according to Web UI - Default: stringdefault.StaticString("case_sensitive"), - Validators: []validator.String{ - validators.NewStringEnumValidator([]basetypes.StringValue{ - types.StringValue("case_sensitive"), - types.StringValue("case_insensitive"), - }), - }, - }, - "type": schema.StringAttribute{ - MarkdownDescription: "The type of the flag, could be either static or regex", - Optional: true, - Computed: true, - // default value is "static" according to ctfcli - Default: stringdefault.StaticString("static"), - Validators: []validator.String{ - validators.NewStringEnumValidator([]basetypes.StringValue{ - types.StringValue("static"), - types.StringValue("regex"), - }), - }, - }, - } -} - -func (data *FlagSubresourceModel) Create(ctx context.Context, diags diag.Diagnostics, client *api.Client, challengeID int) { - res, err := client.PostFlags(&api.PostFlagsParams{ - Challenge: challengeID, - Content: data.Content.ValueString(), - Data: data.Data.ValueString(), - Type: data.Type.ValueString(), - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to create flag, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "created a flag") - - data.ID = types.StringValue(strconv.Itoa(res.ID)) -} - -func (data *FlagSubresourceModel) Update(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - res, err := client.PatchFlag(data.ID.ValueString(), &api.PatchFlagParams{ - Content: data.Content.ValueString(), - Data: data.Data.ValueString(), - Type: data.Type.ValueString(), - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to update flag, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "updated a flag") - - data.Content = types.StringValue(res.Content) - data.Data = types.StringValue(res.Data) - data.Type = types.StringValue(res.Type) -} - -func (data *FlagSubresourceModel) Delete(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - if err := client.DeleteFlag(data.ID.ValueString(), api.WithContext(ctx)); err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to delete flag, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "deleted a flag") -} diff --git a/provider/flag_resource.go b/provider/flag_resource.go new file mode 100644 index 0000000..d63a4b3 --- /dev/null +++ b/provider/flag_resource.go @@ -0,0 +1,226 @@ +package provider + +import ( + "context" + "fmt" + "strconv" + + "github.com/ctfer-io/go-ctfd/api" + "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" + "github.com/ctfer-io/terraform-provider-ctfd/provider/validators" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = (*flagResource)(nil) + _ resource.ResourceWithConfigure = (*flagResource)(nil) + _ resource.ResourceWithImportState = (*flagResource)(nil) +) + +func NewFlagResource() resource.Resource { + return &flagResource{} +} + +type flagResource struct { + client *api.Client +} + +type flagResourceModel struct { + ID types.String `tfsdk:"id"` + ChallengeID types.String `tfsdk:"challenge_id"` + Content types.String `tfsdk:"content"` + Data types.String `tfsdk:"data"` + Type types.String `tfsdk:"type"` +} + +func (r *flagResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_flag" +} + +func (r *flagResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Identifier of the flag, used internally to handle the CTFd corresponding object.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "challenge_id": schema.StringAttribute{ + MarkdownDescription: "Challenge of the flag.", + Required: true, + }, + "content": schema.StringAttribute{ + MarkdownDescription: "The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge.", + Required: true, + Sensitive: true, + }, + "data": schema.StringAttribute{ + MarkdownDescription: "The flag sensitivity information, either case_sensitive or case_insensitive", + Optional: true, + Computed: true, + // default value is "" (empty string) according to Web UI + Default: stringdefault.StaticString("case_sensitive"), + Validators: []validator.String{ + validators.NewStringEnumValidator([]basetypes.StringValue{ + types.StringValue("case_sensitive"), + types.StringValue("case_insensitive"), + }), + }, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The type of the flag, could be either static or regex", + Optional: true, + Computed: true, + // default value is "static" according to ctfcli + Default: stringdefault.StaticString("static"), + Validators: []validator.String{ + validators.NewStringEnumValidator([]basetypes.StringValue{ + types.StringValue("static"), + types.StringValue("regex"), + }), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *flagResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/ctfer-io/terraform-provider-ctfd", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *flagResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Create flag + res, err := r.client.PostFlags(&api.PostFlagsParams{ + Challenge: utils.Atoi(data.ChallengeID.ValueString()), + Content: data.Content.ValueString(), + Data: data.Data.ValueString(), + Type: data.Type.ValueString(), + }, api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to create flag, got error: %s", err), + ) + return + } + + tflog.Trace(ctx, "created a flag") + + // Save computed attributes in state + data.ID = types.StringValue(strconv.Itoa(res.ID)) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *flagResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve flag + res, err := r.client.GetFlag(data.ID.ValueString(), api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to read flag %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + // Upsert values + data.ChallengeID = types.StringValue(strconv.Itoa(res.ChallengeID)) + data.Content = types.StringValue(res.Content) + data.Data = types.StringValue(res.Data) + data.Type = types.StringValue(res.Type) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *flagResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update flag + if _, err := r.client.PatchFlag(data.ID.ValueString(), &api.PatchFlagParams{ + ID: data.ID.ValueString(), + Content: data.Content.ValueString(), + Data: data.Data.ValueString(), + Type: data.Type.ValueString(), + }, api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to update flag %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *flagResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data flagResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteFlag(data.ID.ValueString(), api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete flag %s, got error: %s", data.ID.ValueString(), err)) + return + } +} + +func (r *flagResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + + // Automatically call r.Read +} diff --git a/provider/flag_resource_test.go b/provider/flag_resource_test.go new file mode 100644 index 0000000..0a73101 --- /dev/null +++ b/provider/flag_resource_test.go @@ -0,0 +1,71 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_Flag_Lifecycle(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_flag" "static" { + challenge_id = ctfd_challenge.example.id + content = "This is a first flag" + type = "static" +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + // Verify dynamic values have any value set in the state. + // resource.TestCheckResourceAttr("ctfd_flag.first", "requirements.#", "0"), + ), + }, + // ImportState testing + { + ResourceName: "ctfd_flag.static", + ImportState: true, + ImportStateVerify: true, + }, + // Update and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_flag" "static" { + challenge_id = ctfd_challenge.example.id + content = "This is a first flag" + data = "case_insensitive" + type = "static" +} + +resource "ctfd_flag" "regex" { + challenge_id = ctfd_challenge.example.id + content = "CTFER{.*}" + type = "regex" +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + // resource.TestCheckResourceAttr("ctfd_flag.first", "requirements.#", "0"), + // resource.TestCheckResourceAttr("ctfd_flag.second", "requirements.#", "1"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} diff --git a/provider/provider.go b/provider/provider.go index f8a3595..d1be7a5 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -188,6 +188,7 @@ func (p *CTFdProvider) Configure(ctx context.Context, req provider.ConfigureRequ func (p *CTFdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewChallengeResource, + NewFlagResource, NewFileResource, NewUserResource, NewTeamResource,