diff --git a/docs/data-sources/apikeys.md b/docs/data-sources/apikeys.md index d0d8ddf..6faac0d 100644 --- a/docs/data-sources/apikeys.md +++ b/docs/data-sources/apikeys.md @@ -28,4 +28,14 @@ data "supabase_apikeys" "production" { ### Read-Only - `anon_key` (String, Sensitive) Anonymous API key for the project +- `publishable_key` (String, Sensitive) Publishable API key for the project +- `secret_keys` (Attributes List, Sensitive) List of secret API keys for the project (see [below for nested schema](#nestedatt--secret_keys)) - `service_role_key` (String, Sensitive) Service role API key for the project + + +### Nested Schema for `secret_keys` + +Read-Only: + +- `api_key` (String, Sensitive) The secret API key value +- `name` (String) Name of the secret key diff --git a/docs/resources/apikey.md b/docs/resources/apikey.md new file mode 100644 index 0000000..c00f05e --- /dev/null +++ b/docs/resources/apikey.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "supabase_apikey Resource - terraform-provider-supabase" +subcategory: "" +description: |- + API Key resource +--- + +# supabase_apikey (Resource) + +API Key resource + +## Example Usage + +```terraform +resource "supabase_apikey" "new" { + project_ref = "mayuaycdtijbctgqbycg" + name = "test" +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the API key +- `project_ref` (String) Project reference ID + +### Optional + +- `description` (String) Description of the API key + +### Read-Only + +- `api_key` (String, Sensitive) API key +- `id` (String) API key identifier +- `secret_jwt_template` (Attributes) Secret JWT template (see [below for nested schema](#nestedatt--secret_jwt_template)) +- `type` (String) Type of the API key + + +### Nested Schema for `secret_jwt_template` + +Read-Only: + +- `role` (String) Role of the secret JWT template + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# The ID is the project reference and a unique identifier of the key separated by '/' +terraform import supabase_apikey.example / +``` diff --git a/docs/schema.json b/docs/schema.json index 637976e..658dae3 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -24,6 +24,68 @@ } }, "resource_schemas": { + "supabase_apikey": { + "version": 0, + "block": { + "attributes": { + "api_key": { + "type": "string", + "description": "API key", + "description_kind": "markdown", + "computed": true, + "sensitive": true + }, + "description": { + "type": "string", + "description": "Description of the API key", + "description_kind": "markdown", + "optional": true + }, + "id": { + "type": "string", + "description": "API key identifier", + "description_kind": "markdown", + "computed": true + }, + "name": { + "type": "string", + "description": "Name of the API key", + "description_kind": "markdown", + "required": true + }, + "project_ref": { + "type": "string", + "description": "Project reference ID", + "description_kind": "markdown", + "required": true + }, + "secret_jwt_template": { + "nested_type": { + "attributes": { + "role": { + "type": "string", + "description": "Role of the secret JWT template", + "description_kind": "markdown", + "computed": true + } + }, + "nesting_mode": "single" + }, + "description": "Secret JWT template", + "description_kind": "markdown", + "computed": true + }, + "type": { + "type": "string", + "description": "Type of the API key", + "description_kind": "markdown", + "computed": true + } + }, + "description": "API Key resource", + "description_kind": "markdown" + } + }, "supabase_branch": { "version": 0, "block": { @@ -240,6 +302,37 @@ "description_kind": "markdown", "required": true }, + "publishable_key": { + "type": "string", + "description": "Publishable API key for the project", + "description_kind": "markdown", + "computed": true, + "sensitive": true + }, + "secret_keys": { + "nested_type": { + "attributes": { + "api_key": { + "type": "string", + "description": "The secret API key value", + "description_kind": "markdown", + "computed": true, + "sensitive": true + }, + "name": { + "type": "string", + "description": "Name of the secret key", + "description_kind": "markdown", + "computed": true + } + }, + "nesting_mode": "list" + }, + "description": "List of secret API keys for the project", + "description_kind": "markdown", + "computed": true, + "sensitive": true + }, "service_role_key": { "type": "string", "description": "Service role API key for the project", diff --git a/examples/examples.go b/examples/examples.go index 8e37835..adbf701 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -9,6 +9,8 @@ var ( ProjectResourceConfig string //go:embed resources/supabase_branch/resource.tf BranchResourceConfig string + //go:embed resources/supabase_apikey/resource.tf + ApiKeyResourceConfig string //go:embed data-sources/supabase_branch/data-source.tf BranchDataSourceConfig string //go:embed data-sources/supabase_pooler/data-source.tf diff --git a/examples/resources/supabase_apikey/import.sh b/examples/resources/supabase_apikey/import.sh new file mode 100644 index 0000000..548b673 --- /dev/null +++ b/examples/resources/supabase_apikey/import.sh @@ -0,0 +1,2 @@ +# The ID is the project reference and a unique identifier of the key separated by '/' +terraform import supabase_apikey.example / \ No newline at end of file diff --git a/examples/resources/supabase_apikey/resource.tf b/examples/resources/supabase_apikey/resource.tf new file mode 100644 index 0000000..86922d8 --- /dev/null +++ b/examples/resources/supabase_apikey/resource.tf @@ -0,0 +1,4 @@ +resource "supabase_apikey" "new" { + project_ref = "mayuaycdtijbctgqbycg" + name = "test" +} diff --git a/internal/provider/apikey_resource.go b/internal/provider/apikey_resource.go new file mode 100644 index 0000000..b73d035 --- /dev/null +++ b/internal/provider/apikey_resource.go @@ -0,0 +1,422 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/oapi-codegen/nullable" + "github.com/supabase/cli/pkg/api" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &APIKeyResource{} +var _ resource.ResourceWithImportState = &APIKeyResource{} + +func NewApiKeyResource() resource.Resource { + return &APIKeyResource{} +} + +// APIKeysDataSource defines the data source implementation. +type APIKeyResource struct { + client *api.ClientWithResponses +} + +var secretJwtTemplateAttrTypes = map[string]attr.Type{ + "role": types.StringType, +} + +type ApiKeyDatabaseModel struct { + Id types.String `tfsdk:"id"` + ProjectRef types.String `tfsdk:"project_ref"` + ApiKey types.String `tfsdk:"api_key"` + SecretJwtTemplate types.Object `tfsdk:"secret_jwt_template"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + Description types.String `tfsdk:"description"` +} + +func (m ApiKeyDatabaseModel) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "role": types.StringType, + } +} + +// APIKeysDataSourceModel describes the data source data model. +type ApiKeyResourceModel struct { + ProjectRef types.String `tfsdk:"project_ref"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Type types.String `tfsdk:"type"` + ApiKey types.String `tfsdk:"api_key"` + SecretJwtTemplate types.Object `tfsdk:"secret_jwt_template"` + Id types.String `tfsdk:"id"` +} + +func (d *APIKeyResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_apikey" +} + +func (d *APIKeyResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "API Key resource", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "API key identifier", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_ref": schema.StringAttribute{ + MarkdownDescription: "Project reference ID", + Required: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the API key", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description of the API key", + Optional: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "Type of the API key", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "api_key": schema.StringAttribute{ + MarkdownDescription: "API key", + Computed: true, + Sensitive: true, + }, + "secret_jwt_template": schema.SingleNestedAttribute{ + MarkdownDescription: "Secret JWT template", + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + MarkdownDescription: "Role of the secret JWT template", + Computed: true, + }, + }, + }, + }, + } +} + +func (d *APIKeyResource) 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.ClientWithResponses) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *api.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +func (r *APIKeyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data ApiKeyResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(createApiKey(ctx, &data, r.client)...) + if resp.Diagnostics.HasError() { + return + } + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, "created a resource") + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *APIKeyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data ApiKeyResourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(readApiKey(ctx, &data, r.client)...) + if resp.Diagnostics.HasError() { + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *APIKeyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data ApiKeyResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(updateApiKey(ctx, &data, r.client)...) + if resp.Diagnostics.HasError() { + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *APIKeyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + parts := strings.Split(req.ID, "/") + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + `Expected import identifier in the format "project_ref/api_key_id".`, + ) + return + } + + projectRef := strings.TrimSpace(parts[0]) + apiKeyID := strings.TrimSpace(parts[1]) + if projectRef == "" || apiKeyID == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + "Both project_ref and api_key_id must be provided when importing. Example: myprojectref/3603c575-...", + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_ref"), types.StringValue(projectRef))...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(apiKeyID))...) +} + +func (r *APIKeyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data ApiKeyResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(deleteApiKey(ctx, &data, r.client)...) +} + +func readApiKey(ctx context.Context, state *ApiKeyResourceModel, client *api.ClientWithResponses) diag.Diagnostics { + return readApiKeyDatabase(ctx, state, client) +} + +func readApiKeyDatabase(ctx context.Context, state *ApiKeyResourceModel, client *api.ClientWithResponses) diag.Diagnostics { + httpResp, err := client.V1GetProjectApiKeyWithResponse(ctx, state.ProjectRef.ValueString(), uuid.MustParse(state.Id.ValueString()), &api.V1GetProjectApiKeyParams{Reveal: Ptr(true)}) + if err != nil { + msg := fmt.Sprintf("Unable to read apiKey database, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + if httpResp.JSON200 == nil { + msg := fmt.Sprintf("Unable to read apiKey database, got status %d: %s", httpResp.StatusCode(), httpResp.Body) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + idValue := NullableToString(httpResp.JSON200.Id) + apiKeyValue := NullableToString(httpResp.JSON200.ApiKey) + typeValue := NullableToString(httpResp.JSON200.Type) + descriptionValue := NullableToString(httpResp.JSON200.Description) + + database := ApiKeyDatabaseModel{ + Id: idValue, + ApiKey: apiKeyValue, + Name: types.StringValue(httpResp.JSON200.Name), + Type: typeValue, + Description: descriptionValue, + } + + var secretJwtTemplate types.Object + if httpResp.JSON200.SecretJwtTemplate.IsSpecified() && !httpResp.JSON200.SecretJwtTemplate.IsNull() { + templateMap := httpResp.JSON200.SecretJwtTemplate.MustGet() + roleValue := "" + if role, ok := templateMap["role"].(string); ok { + roleValue = role + } + obj, diags := types.ObjectValue(secretJwtTemplateAttrTypes, map[string]attr.Value{ + "role": types.StringValue(roleValue), + }) + if diags.HasError() { + return diags + } + secretJwtTemplate = obj + } else { + obj, diags := types.ObjectValue(secretJwtTemplateAttrTypes, map[string]attr.Value{ + "role": types.StringNull(), + }) + if diags.HasError() { + return diags + } + secretJwtTemplate = obj + } + + database.SecretJwtTemplate = secretJwtTemplate + state.Id = database.Id + state.ApiKey = database.ApiKey + state.Name = database.Name + state.Type = database.Type + state.Description = database.Description + state.SecretJwtTemplate = database.SecretJwtTemplate + return nil +} + +func createApiKey(ctx context.Context, plan *ApiKeyResourceModel, client *api.ClientWithResponses) diag.Diagnostics { + reveal := Ptr(true) + resp, err := client.V1GetProjectApiKeysWithResponse(ctx, plan.ProjectRef.ValueString(), &api.V1GetProjectApiKeysParams{Reveal: reveal}) + if err != nil { + msg := fmt.Sprintf("Unable to read apiKeys, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + // 1. Check if default publishable key exist, create it if it doesn't + hasDefaultPublishable := false + + if resp.JSON200 != nil { + for _, key := range *resp.JSON200 { + if key.Name == "default" { + if key.Type.IsSpecified() && !key.Type.IsNull() { + keyType := key.Type.MustGet() + if keyType == api.ApiKeyResponseTypePublishable { + hasDefaultPublishable = true + } + } + } + } + } + + if !hasDefaultPublishable { + httpRespDefaultPublishable, errDefaultPublishable := client.V1CreateProjectApiKeyWithResponse(ctx, plan.ProjectRef.ValueString(), &api.V1CreateProjectApiKeyParams{Reveal: reveal}, api.CreateApiKeyBody{ + Name: "default", + Type: api.CreateApiKeyBodyTypePublishable, + Description: nullable.Nullable[string]{}, + SecretJwtTemplate: nullable.Nullable[map[string]interface{}]{}, + }) + if errDefaultPublishable != nil { + msg := fmt.Sprintf("Unable to create default publishable apiKey, got error: %s", errDefaultPublishable) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + if httpRespDefaultPublishable.JSON201 == nil { + msg := fmt.Sprintf("Unable to create default publishable apiKey, got status %d: %s", httpRespDefaultPublishable.StatusCode(), httpRespDefaultPublishable.Body) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + } + + // 2. Create apiKey + httpResp, err := client.V1CreateProjectApiKeyWithResponse(ctx, plan.ProjectRef.ValueString(), &api.V1CreateProjectApiKeyParams{Reveal: reveal}, api.CreateApiKeyBody{ + Name: plan.Name.ValueString(), + Type: api.CreateApiKeyBodyTypeSecret, + Description: nullable.Nullable[string]{}, + SecretJwtTemplate: nullable.NewNullableWithValue(map[string]interface{}{"role": "service_role"}), + }) + + if err != nil { + msg := fmt.Sprintf("Unable to create apiKey, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + if httpResp.JSON201 == nil { + msg := fmt.Sprintf("Unable to create apiKey, got status %d: %s", httpResp.StatusCode(), httpResp.Body) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + // Update computed fields from creation response + plan.Id = NullableToString(httpResp.JSON201.Id) + plan.ApiKey = NullableToString(httpResp.JSON201.ApiKey) + plan.Type = NullableToString(httpResp.JSON201.Type) + + obj, diags := types.ObjectValue(secretJwtTemplateAttrTypes, map[string]attr.Value{ + "role": types.StringValue("service_role"), + }) + if diags.HasError() { + return diags + } + plan.SecretJwtTemplate = obj + + return readApiKeyDatabase(ctx, plan, client) +} + +func updateApiKey(ctx context.Context, plan *ApiKeyResourceModel, client *api.ClientWithResponses) diag.Diagnostics { + var secretJwtTemplate nullable.Nullable[map[string]interface{}] + if plan.Type.ValueString() == string(api.ApiKeyResponseTypeSecret) { + secretJwtTemplate = nullable.NewNullableWithValue(map[string]interface{}{"role": "service_role"}) + } else { + secretJwtTemplate = nullable.Nullable[map[string]interface{}]{} + } + + var description nullable.Nullable[string] + if plan.Description.IsNull() || plan.Description.IsUnknown() { + description = nullable.Nullable[string]{} + } else { + description = nullable.NewNullableWithValue(plan.Description.ValueString()) + } + + httpResp, err := client.V1UpdateProjectApiKeyWithResponse(ctx, plan.ProjectRef.ValueString(), uuid.MustParse(plan.Id.ValueString()), &api.V1UpdateProjectApiKeyParams{Reveal: Ptr(true)}, api.UpdateApiKeyBody{ + Name: plan.Name.ValueStringPointer(), + Description: description, + SecretJwtTemplate: secretJwtTemplate, + }) + + if err != nil { + msg := fmt.Sprintf("Unable to update apiKey, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + if httpResp.JSON200 == nil { + msg := fmt.Sprintf("Unable to update apiKey, got status %d: %s", httpResp.StatusCode(), httpResp.Body) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + plan.Description = NullableToString(httpResp.JSON200.Description) + + return readApiKeyDatabase(ctx, plan, client) +} + +func deleteApiKey(ctx context.Context, state *ApiKeyResourceModel, client *api.ClientWithResponses) diag.Diagnostics { + httpResp, err := client.V1DeleteProjectApiKeyWithResponse(ctx, state.ProjectRef.ValueString(), uuid.MustParse(state.Id.ValueString()), &api.V1DeleteProjectApiKeyParams{Reveal: Ptr(true)}) + if err != nil { + msg := fmt.Sprintf("Unable to delete apiKey, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + if httpResp.StatusCode() != http.StatusOK { + msg := fmt.Sprintf("Unable to delete apiKey, got status %d: %s", httpResp.StatusCode(), httpResp.Body) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + return nil +} diff --git a/internal/provider/apikey_resource_test.go b/internal/provider/apikey_resource_test.go new file mode 100644 index 0000000..5b20ffe --- /dev/null +++ b/internal/provider/apikey_resource_test.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/oapi-codegen/nullable" + "github.com/supabase/cli/pkg/api" + "github.com/supabase/terraform-provider-supabase/examples" + "gopkg.in/h2non/gock.v1" +) + +const testProjectRef = "mayuaycdtijbctgqbycg" //nolint:gosec + +func TestAccApiKeyResource(t *testing.T) { + // Setup mock api + defer gock.OffAll() + // Step 1: create + testApiKeyUUID := uuid.New() + apiKeysEndpoint := fmt.Sprintf("/v1/projects/%s/api-keys", testProjectRef) + apiKeyEndpoint := fmt.Sprintf("%s/%s", apiKeysEndpoint, testApiKeyUUID.String()) + gock.New("https://api.supabase.com"). + Get(apiKeysEndpoint). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{ + { + Name: "anon", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeLegacy), + ApiKey: nullable.NewNullableWithValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.anon"), + }, + { + Name: "service_role", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeLegacy), + ApiKey: nullable.NewNullableWithValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.service_role"), + }, + }) + gock.New("https://api.supabase.com"). + Post(apiKeysEndpoint). + Reply(http.StatusCreated). + JSON(api.ApiKeyResponse{ + Id: nullable.NewNullableWithValue(uuid.New().String()), + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullableWithValue("sb_publishable_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + }) + gock.New("https://api.supabase.com"). + Post(apiKeysEndpoint). + Reply(http.StatusCreated). + JSON(api.ApiKeyResponse{ + Id: nullable.NewNullableWithValue(testApiKeyUUID.String()), + Name: "test", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeSecret), + ApiKey: nullable.NewNullableWithValue("sb_secret_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + }) + gock.New("https://api.supabase.com"). + Get(apiKeyEndpoint). + Persist(). + Reply(http.StatusOK). + JSON(api.ApiKeyResponse{ + Id: nullable.NewNullableWithValue(testApiKeyUUID.String()), + Name: "test", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeSecret), + ApiKey: nullable.NewNullableWithValue("sb_secret_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + SecretJwtTemplate: nullable.NewNullableWithValue(map[string]interface{}{ + "role": "service_role", + }), + }) + gock.New("https://api.supabase.com"). + Delete(apiKeyEndpoint). + Reply(http.StatusOK) + + // Run test + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: examples.ApiKeyResourceConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("supabase_apikey.new", "id", testApiKeyUUID.String()), + ), + }, + // ImportState testing + { + ResourceName: "supabase_apikey.new", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"name", "project_ref"}, + ImportStateId: fmt.Sprintf("%s/%s", testProjectRef, testApiKeyUUID.String()), + }, + // Update and Read testing + { + Config: testAccApikeyResourceConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("supabase_apikey.new", "name", "test"), + resource.TestCheckResourceAttr("supabase_apikey.new", "project_ref", testProjectRef), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +const testAccApikeyResourceConfig = ` +resource "supabase_apikey" "new" { + project_ref = "` + testProjectRef + `" + name = "test" +} +` diff --git a/internal/provider/apikeys_data_source.go b/internal/provider/apikeys_data_source.go index d010a2e..f6ede2c 100644 --- a/internal/provider/apikeys_data_source.go +++ b/internal/provider/apikeys_data_source.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" @@ -31,6 +32,8 @@ type APIKeysDataSourceModel struct { ProjectRef types.String `tfsdk:"project_ref"` AnonKey types.String `tfsdk:"anon_key"` ServiceRoleKey types.String `tfsdk:"service_role_key"` + PublishableKey types.String `tfsdk:"publishable_key"` + SecretKeys types.List `tfsdk:"secret_keys"` } func (d *APIKeysDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { @@ -56,6 +59,29 @@ func (d *APIKeysDataSource) Schema(ctx context.Context, req datasource.SchemaReq Computed: true, Sensitive: true, }, + "publishable_key": schema.StringAttribute{ + MarkdownDescription: "Publishable API key for the project", + Computed: true, + Sensitive: true, + }, + "secret_keys": schema.ListNestedAttribute{ + MarkdownDescription: "List of secret API keys for the project", + Computed: true, + Sensitive: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the secret key", + Computed: true, + }, + "api_key": schema.StringAttribute{ + MarkdownDescription: "The secret API key value", + Computed: true, + Sensitive: true, + }, + }, + }, + }, }, } } @@ -87,7 +113,7 @@ func (d *APIKeysDataSource) Read(ctx context.Context, req datasource.ReadRequest return } - httpResp, err := d.client.V1GetProjectApiKeysWithResponse(ctx, data.ProjectRef.ValueString(), &api.V1GetProjectApiKeysParams{}) + httpResp, err := d.client.V1GetProjectApiKeysWithResponse(ctx, data.ProjectRef.ValueString(), &api.V1GetProjectApiKeysParams{Reveal: Ptr(true)}) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read API keys, got error: %s", err)) return @@ -98,15 +124,50 @@ func (d *APIKeysDataSource) Read(ctx context.Context, req datasource.ReadRequest return } + objectType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "api_key": types.StringType, + }, + } + var secretKeyObjects []attr.Value + for _, key := range *httpResp.JSON200 { - switch key.Name { - case "anon": - data.AnonKey = NullableToString(key.ApiKey) - case "service_role": - data.ServiceRoleKey = NullableToString(key.ApiKey) + if key.Type.IsSpecified() && !key.Type.IsNull() { + keyType := key.Type.MustGet() + + switch keyType { + case api.ApiKeyResponseTypeLegacy: + if key.Name == "anon" { + data.AnonKey = NullableToString(key.ApiKey) + } + if key.Name == "service_role" { + data.ServiceRoleKey = NullableToString(key.ApiKey) + } + case api.ApiKeyResponseTypePublishable: + data.PublishableKey = NullableToString(key.ApiKey) + case api.ApiKeyResponseTypeSecret: + obj, diags := types.ObjectValue(objectType.AttrTypes, map[string]attr.Value{ + "name": types.StringValue(key.Name), + "api_key": NullableToString(key.ApiKey), + }) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + secretKeyObjects = append(secretKeyObjects, obj) + } } } + // Build list directly from object values + secretKeysList, diags := types.ListValue(objectType, secretKeyObjects) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + data.SecretKeys = secretKeysList + tflog.Trace(ctx, "read API keys") // Save data into Terraform state diff --git a/internal/provider/apikeys_data_source_test.go b/internal/provider/apikeys_data_source_test.go index 64379ae..f691365 100644 --- a/internal/provider/apikeys_data_source_test.go +++ b/internal/provider/apikeys_data_source_test.go @@ -24,12 +24,29 @@ func TestAccProjectAPIKeysDataSource(t *testing.T) { JSON([]api.ApiKeyResponse{ { Name: "anon", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeLegacy), ApiKey: nullable.NewNullableWithValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.anon"), }, { Name: "service_role", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeLegacy), ApiKey: nullable.NewNullableWithValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.service_role"), }, + { + Name: "publishable", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullableWithValue("sb_publishable_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + }, + { + Name: "secret", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeSecret), + ApiKey: nullable.NewNullableWithValue("sb_secret_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + }, + { + Name: "other_secret", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeSecret), + ApiKey: nullable.NewNullableWithValue("sb_secret_eybcCI6kNiIsiR5UCJ9VGpzI1IciOJhJIXIn"), + }, }) resource.Test(t, resource.TestCase{ @@ -42,6 +59,12 @@ func TestAccProjectAPIKeysDataSource(t *testing.T) { Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("data.supabase_apikeys.production", "anon_key", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.anon"), resource.TestCheckResourceAttr("data.supabase_apikeys.production", "service_role_key", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.service_role"), + resource.TestCheckResourceAttr("data.supabase_apikeys.production", "publishable_key", "sb_publishable_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + resource.TestCheckResourceAttr("data.supabase_apikeys.production", "secret_keys.#", "2"), + resource.TestCheckResourceAttr("data.supabase_apikeys.production", "secret_keys.0.name", "secret"), + resource.TestCheckResourceAttr("data.supabase_apikeys.production", "secret_keys.0.api_key", "sb_secret_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + resource.TestCheckResourceAttr("data.supabase_apikeys.production", "secret_keys.1.name", "other_secret"), + resource.TestCheckResourceAttr("data.supabase_apikeys.production", "secret_keys.1.api_key", "sb_secret_eybcCI6kNiIsiR5UCJ9VGpzI1IciOJhJIXIn"), ), }, }, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 74a55e8..0b5e83a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -89,6 +89,7 @@ func (p *SupabaseProvider) Resources(ctx context.Context) []func() resource.Reso NewProjectResource, NewSettingsResource, NewBranchResource, + NewApiKeyResource, } } diff --git a/internal/provider/utils.go b/internal/provider/utils.go index 6522a81..b4f91cc 100644 --- a/internal/provider/utils.go +++ b/internal/provider/utils.go @@ -11,10 +11,9 @@ func Ptr[T any](v T) *T { // NullableToString converts an oapi-codegen [nullable.Nullable] to an appropriate // terraform string type. -func NullableToString(n nullable.Nullable[string]) tftypes.String { +func NullableToString[T ~string](n nullable.Nullable[T]) tftypes.String { if n.IsSpecified() && !n.IsNull() { - // MustGet is safe when the value is specified and not null - return tftypes.StringValue(n.MustGet()) + return tftypes.StringValue(string(n.MustGet())) } return tftypes.StringNull()