diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 7d83bef..e05881f 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -46,6 +46,9 @@ jobs: package: ./internal/datasources/app project_id_required: true app_fixture_required: true + - name: API key data source + package: ./internal/datasources/apikey + project_id_required: true - name: Deployment data source package: ./internal/datasources/deployment project_id_required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index f0473e6..49904ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ First public v1 release candidate: - Provider configuration for `api_key`, `base_url`, and `project_id`. - Durable `kernel_project`, `kernel_browser_pool`, and `kernel_extension` resources. -- Lookup-only `kernel_app`, `kernel_browser_pool`, `kernel_deployment`, `kernel_project`, `kernel_profile`, `kernel_proxy`, and `kernel_extension` data sources. +- Lookup-only `kernel_api_key`, `kernel_app`, `kernel_browser_pool`, `kernel_deployment`, `kernel_project`, `kernel_profile`, `kernel_proxy`, and `kernel_extension` data sources. - Stable import for every registered resource, including project-qualified browser-pool and extension forms. - Unit tests, generated Terraform docs, Terraform examples, CI checks, and an opt-in live acceptance matrix. @@ -16,7 +16,6 @@ Intentionally deferred from the first public v1 because their durable API, SDK, - Runtime browser/session operations such as acquire, release, flush, app invocation, screenshots, logs, live view, and force recovery. - API key, profile, proxy, and deployment resources. -- API key data source. - `force_destroy` browser-pool deletion. - Terraform Plugin Framework code generation. diff --git a/README.md b/README.md index 4c03b5e..4224900 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Resources: Data sources: +- `kernel_api_key` - `kernel_app` - `kernel_browser_pool` - `kernel_deployment` diff --git a/docs/acceptance.md b/docs/acceptance.md index 697f0bc..3b82c1e 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -50,7 +50,7 @@ Actions secrets and must not be printed. | `kernel_proxy` data source | Test present | A uniquely created managed datacenter proxy is read by canonical ID and exact name through explicit and provider-default project scope; durable type/protocol metadata, no-drift planning, and post-cleanup coded `not_found` are verified without fixture credentials. | None. | | `kernel_app` data source | Test present | A release-owned running app version is read by exact name/version through explicit and provider-default project scope; canonical deployment metadata and no-drift planning are verified without invocation. Unit coverage verifies action-name and environment-key flattening without environment values. | Keep `KERNEL_ACC_APP_NAME` and `KERNEL_ACC_APP_VERSION` pointed at exactly one running app version in the acceptance project. | | `kernel_deployment` data source | Test present | The deployment backing the release-owned app fixture is read by canonical ID through explicit and provider-default project scope; direct GET metadata and no-drift planning are verified without logs or event streams. Unit coverage verifies that only environment variable names enter state. | Keep the release-owned app fixture running so its deployment ID remains readable. | -| `kernel_api_key` data source | Deferred; unregistered | No provider surface yet. | Wait for a tagged SDK with exact-name filtering, then add masked ID/name lookup acceptance. | +| `kernel_api_key` data source | Test present | A uniquely created project-scoped key is read by canonical ID and byte-exact name; masked metadata, no plaintext state, ambiguity behavior, no-drift planning, cleanup, and coded post-cleanup absence are covered. | Run with an organization-wide administrative `KERNEL_API_KEY`; project-scoped credentials cannot create or delete the fixture. | | Profile, proxy, deployment, and API-key resources | Deferred; unregistered | No provider surfaces yet. | Enter the matrix only after their documented API/SDK/state blockers are resolved and implementation lands. | "Test present" describes code in the repository; it does not claim a run @@ -58,7 +58,7 @@ against the release commit. The release record below supplies that evidence. ## Current Commands -Run the ten existing packages independently for fast failure isolation: +Run the eleven existing packages independently for fast failure isolation: ```sh go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc @@ -69,6 +69,7 @@ go test -count=1 -timeout=30m -v ./internal/datasources/extension -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/profile -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/proxy -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/app -run TestAcc +go test -count=1 -timeout=30m -v ./internal/datasources/apikey -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/deployment -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/browserpool -run TestAcc ``` diff --git a/docs/api-key-state.md b/docs/api-key-state.md index 86c8cae..e839adc 100644 --- a/docs/api-key-state.md +++ b/docs/api-key-state.md @@ -2,8 +2,9 @@ ## Decision -The masked `kernel_api_key` data source is safe to implement after a tagged -Kernel Go SDK exposes the API's exact-name list filter. +The masked `kernel_api_key` data source is supported. It uses the tagged Kernel +Go SDK's paginated query filter, then enforces byte-exact name equality in the +provider and diagnoses ambiguous names. The `kernel_api_key` resource is deferred. Its Terraform state shape is accepted below, but Create and Rotate must not ship until the API provides @@ -51,9 +52,11 @@ has these properties: - A deleted key is hidden from ordinary Get and a repeated Delete returns `not_found`. -The API supports an exact-name List filter. It is generated in the pending Go -SDK v0.77.0 release, so the provider must wait for that tag rather than adding -direct HTTP or a second client. +The API supports substring query today and has a dedicated exact-name List +filter pending in Go SDK v0.77.0. The data source does not require the pending +filter: it scans every query page, post-filters names byte-for-byte, and +deduplicates canonical IDs. The provider still uses only the tagged SDK and +does not add direct HTTP or a second client. ## Future Resource State @@ -180,9 +183,9 @@ replay contract. ## Masked Data Source -The data source is independent of plaintext lifecycle and may ship first. It -should accept exactly one of canonical `id` or exact `name`, scan all pages for -name lookup, deduplicate by ID, and diagnose zero or multiple non-deleted +The data source is independent of plaintext lifecycle. It accepts exactly one +of canonical `id` or exact `name`, scans all pages for name lookup, deduplicates +by ID, and diagnoses zero or multiple non-deleted matches. Expired-but-not-deleted keys remain visible because they are durable records under the current API status definition. The API's name filter follows the production database's case- and accent-insensitive collation; the provider @@ -195,7 +198,6 @@ or provider-authentication identity. ## Unblocking Checklist -- Tag an SDK release containing the exact API-key name filter. - Add replayable idempotency for API-key Create and Rotate. - Add a stable current-key/project-scope signal or coded self-rotation rejection plus effective-scope metadata. diff --git a/docs/architecture.md b/docs/architecture.md index 0f282c7..16c5112 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,7 @@ Core v1 resources: Core v1 data sources: +- `kernel_api_key` - `kernel_project` - `kernel_browser_pool` - `kernel_profile` @@ -67,7 +68,6 @@ Core v1 data sources: Late or conditional v1 work: - `kernel_proxy` resource, after write-only credential/import semantics are accepted and a tagged SDK exposes in-place rename -- masked `kernel_api_key` metadata lookup - `kernel_api_key` resource, only after plaintext-once, retry, rotation, import, and provider self-use semantics are accepted - project limits, only after their lifecycle is clearly separate from basic project management @@ -75,9 +75,10 @@ Blocked candidates must remain unimplemented until the API and a tagged SDK expo The accepted plaintext, import, rotation, and self-use model for API keys is defined in [API Key Terraform State Design](api-key-state.md). The masked data -source may proceed after the exact-name SDK surface is tagged. The resource -remains blocked on replayable Create/Rotate idempotency and a current-key -rotation guard with effective authenticated project-scope metadata. +source scans the tagged SDK's paginated query results and enforces byte-exact +name equality in provider code. The resource remains blocked on replayable +Create/Rotate idempotency and a current-key rotation guard with effective +authenticated project-scope metadata. `kernel_deployment` remains core v1 scope but is currently blocked until a tagged SDK exposes source provenance, the API supports deterministic durable diff --git a/docs/data-sources/api_key.md b/docs/data-sources/api_key.md new file mode 100644 index 0000000..079abc3 --- /dev/null +++ b/docs/data-sources/api_key.md @@ -0,0 +1,32 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_api_key Data Source - Kernel" +subcategory: "" +description: |- + Lookup masked metadata for a non-deleted Kernel API key by canonical ID or exact name. +--- + +# kernel_api_key (Data Source) + +Lookup masked metadata for a non-deleted Kernel API key by canonical ID or exact name. + + + + +## Schema + +### Optional + +- `id` (String) Canonical API key ID. +- `name` (String) API key name for exact lookup. Names are not unique, so ambiguous matches fail. + +### Read-Only + +- `created_at` (String) API key creation timestamp. +- `created_by_email` (String) Email address of the key creator. +- `created_by_id` (String) Kernel user ID of the key creator. +- `created_by_name` (String) Display name of the key creator, when available. +- `expires_at` (String) API key expiration timestamp, or null when the key does not expire. +- `masked_key` (String, Sensitive) Masked API key metadata. Plaintext is never returned or stored. +- `project_id` (String) Project ID for a project-scoped key, or null for an organization-wide key. +- `project_name` (String) Project name for a project-scoped key, when available. diff --git a/docs/migration-v1.md b/docs/migration-v1.md index 0514072..1fd9132 100644 --- a/docs/migration-v1.md +++ b/docs/migration-v1.md @@ -64,9 +64,10 @@ path. Managing future content replacement requires Terraform 1.11 or later plus ## Newly Available Lookups -V1 adds lookup-only app and browser-pool data sources alongside project, -profile, proxy, and extension lookups. Data sources do not adopt or mutate the -remote object. Exact lookup fails when no object or multiple objects match. +V1 adds lookup-only API-key, app, browser-pool, and deployment data sources +alongside project, profile, proxy, and extension lookups. Data sources do not +adopt or mutate the remote object. Exact lookup fails when no object or multiple +objects match. API-key lookup returns masked metadata only. ## Deferred Surfaces diff --git a/docs/release.md b/docs/release.md index 8f4eaf7..11c8560 100644 --- a/docs/release.md +++ b/docs/release.md @@ -23,13 +23,14 @@ Use this checklist before publishing a Kernel Terraform provider version. - Review the [v1 migration guide](migration-v1.md) and include it in the release notes. - Use the commands and status table in `docs/acceptance.md` as the single source of truth. The manual `Acceptance` workflow runs all current packages in parallel; add each new package in the same PR as its first live test and keep live tests out of normal PR CI. - Process-level timeouts can bypass Go test cleanup. After an interrupted or hard-timeout run: - 1. In the Kernel dashboard or durable API, find projects, browser pools, extensions, profiles, and proxies named `kernel-tf-*` that were created during the failed workflow run. + 1. In the Kernel dashboard or durable API, find projects, browser pools, extensions, profiles, proxies, and API keys named `kernel-tf-*` that were created during the failed workflow run. 2. Delete leaked browser pools first with `force=false`. If deletion conflicts with a lease, wait for the lease to end; do not force-release or recover the browser from Terraform cleanup. 3. Delete leaked extensions after removing any durable browser-pool references to them. Do not mutate pools or running browsers implicitly. 4. Delete leaked profiles and managed datacenter proxies after removing durable references. Do not run proxy health checks as cleanup. - 5. Delete a leaked project only after its child resources are gone and the organization still has another active project. - 6. Do not delete the release-owned app fixture; it is not created by the acceptance run. - 7. Read each test-owned canonical resource ID and require a 404 before considering cleanup complete. + 5. Delete leaked API keys with an organization-wide administrative key. Never delete the credential running the acceptance workflow. + 6. Delete a leaked project only after its child resources are gone and the organization still has another active project. + 7. Do not delete the release-owned app fixture; it is not created by the acceptance run. + 8. Read each test-owned canonical resource ID and require a 404 before considering cleanup complete. - Verify unscoped API calls send no `X-Kernel-Project-Id` header; it is sent only when a resource-level `project_id` or the provider default resolves a project. - Confirm `terraform-registry-manifest.json` contains protocol `["6.0"]` for Terraform Plugin Framework. - Confirm the repository license before the first public release. The release workflow fails unless a non-empty `LICENSE` exists. diff --git a/examples/README.md b/examples/README.md index d7499b8..f9db2fb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,6 @@ Use local development overrides while the provider is unreleased. See the root [ - [basic-browser-pool](basic-browser-pool) creates a minimal durable browser pool. - [design-preview-browser-pool](design-preview-browser-pool) shows a browser pool shaped for repeated design-preview checks without modeling the browser sessions themselves. - [extension](extension) uploads an immutable extension archive and tracks exact content changes with `filesha256`. -- [lookups](lookups) shows read-only app, browser-pool, deployment, project, profile, proxy, and extension data sources. +- [lookups](lookups) shows read-only API-key, app, browser-pool, deployment, project, profile, proxy, and extension data sources. - [project](project) creates a durable Kernel project with an explicit unique name. - [project-scoped-browser-pool](project-scoped-browser-pool) places a browser pool in an explicit project, overriding the provider-level `project_id` default. diff --git a/examples/lookups/main.tf b/examples/lookups/main.tf index 80d1cdb..52bde6f 100644 --- a/examples/lookups/main.tf +++ b/examples/lookups/main.tf @@ -43,6 +43,15 @@ variable "app_version" { description = "Existing running Kernel app version for exact lookup." } +variable "api_key_name" { + type = string + description = "Existing non-deleted Kernel API key name for exact masked lookup." +} + +data "kernel_api_key" "selected" { + name = var.api_key_name +} + data "kernel_project" "selected" { name = var.project_name } @@ -86,6 +95,7 @@ output "kernel_ids" { proxy_id = data.kernel_proxy.selected.id extension_id = data.kernel_extension.selected.id app_id = data.kernel_app.selected.id + api_key_id = data.kernel_api_key.selected.id deployment_id = data.kernel_deployment.selected.id } } diff --git a/internal/datasources/apikey/datasource.go b/internal/datasources/apikey/datasource.go new file mode 100644 index 0000000..40aa408 --- /dev/null +++ b/internal/datasources/apikey/datasource.go @@ -0,0 +1,339 @@ +package apikey + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/datasources" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var ( + _ datasource.DataSource = (*apiKeyDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*apiKeyDataSource)(nil) +) + +type apiKeyClient interface { + GetAPIKey(context.Context, string) (*kernel.APIKey, error) + ListAPIKeyPage(context.Context, string, int64) (kernelclient.APIKeyPage, error) +} + +type apiKeyDataSource struct { + client apiKeyClient +} + +type apiKeyModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + MaskedKey types.String `tfsdk:"masked_key"` + ProjectID types.String `tfsdk:"project_id"` + ProjectName types.String `tfsdk:"project_name"` + CreatedAt types.String `tfsdk:"created_at"` + ExpiresAt types.String `tfsdk:"expires_at"` + CreatedByID types.String `tfsdk:"created_by_id"` + CreatedByEmail types.String `tfsdk:"created_by_email"` + CreatedByName types.String `tfsdk:"created_by_name"` +} + +func NewDataSource() datasource.DataSource { + return &apiKeyDataSource{} +} + +func newDataSourceWithClient(client apiKeyClient) *apiKeyDataSource { + return &apiKeyDataSource{client: client} +} + +func (d *apiKeyDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_api_key" +} + +func (d *apiKeyDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup masked metadata for a non-deleted Kernel API key by canonical ID or exact name.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Canonical API key ID.", + }, + "name": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "API key name for exact lookup. Names are not unique, so ambiguous matches fail.", + }, + "masked_key": dschema.StringAttribute{ + Computed: true, + Sensitive: true, + MarkdownDescription: "Masked API key metadata. Plaintext is never returned or stored.", + }, + "project_id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Project ID for a project-scoped key, or null for an organization-wide key.", + }, + "project_name": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Project name for a project-scoped key, when available.", + }, + "created_at": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "API key creation timestamp.", + }, + "expires_at": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "API key expiration timestamp, or null when the key does not expire.", + }, + "created_by_id": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Kernel user ID of the key creator.", + }, + "created_by_email": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Email address of the key creator.", + }, + "created_by_name": dschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Display name of the key creator, when available.", + }, + }, + } +} + +func (d *apiKeyDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(apiKeyClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the API key data source durable client contract.", + ) + return + } + d.client = client +} + +func (d *apiKeyDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config apiKeyModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + state, diags := d.read(ctx, config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (d *apiKeyDataSource) read(ctx context.Context, config apiKeyModel) (apiKeyModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError("Missing Kernel Client", "The API key data source was not configured with a Kernel client.") + return apiKeyModel{}, diags + } + + selector, selectorDiags := datasources.ResolveIDNameSelector("API Key", "kernel_api_key", config.ID, config.Name) + diags.Append(selectorDiags...) + if diags.HasError() { + return apiKeyModel{}, diags + } + + if selector.HasID { + state, getDiags := d.get(ctx, config.ID.ValueString()) + diags.Append(getDiags...) + return state, diags + } + if selector.HasName { + state, lookupDiags := d.lookupName(ctx, config.Name.ValueString()) + diags.Append(lookupDiags...) + return state, diags + } + + diags.AddError("Missing API Key Selector", "Configure id or name for kernel_api_key.") + return apiKeyModel{}, diags +} + +func (d *apiKeyDataSource) get(ctx context.Context, id string) (apiKeyModel, diag.Diagnostics) { + var diags diag.Diagnostics + key, err := d.client.GetAPIKey(ctx, id) + if err != nil { + diags.AddError("Read Kernel API Key", err.Error()) + return apiKeyModel{}, diags + } + if key == nil { + diags.AddError("Read Kernel API Key", "Kernel returned an empty API key response.") + return apiKeyModel{}, diags + } + + state, flattenDiags := flattenAPIKey(*key) + diags.Append(flattenDiags...) + if diags.HasError() { + return apiKeyModel{}, diags + } + if state.ID.ValueString() != id { + diags.AddError("API Key ID Mismatch", "Kernel returned API key "+state.ID.ValueString()+" for id "+id+".") + return apiKeyModel{}, diags + } + return state, diags +} + +func (d *apiKeyDataSource) lookupName(ctx context.Context, name string) (apiKeyModel, diag.Diagnostics) { + var diags diag.Diagnostics + match, count := d.findByName(ctx, name, &diags) + if diags.HasError() { + return apiKeyModel{}, diags + } + switch count { + case 0: + diags.AddError("Lookup Kernel API Key", "No non-deleted Kernel API key found with exact name "+name+".") + return apiKeyModel{}, diags + case 1: + state, flattenDiags := flattenAPIKey(*match) + diags.Append(flattenDiags...) + return state, diags + default: + diags.AddError("Ambiguous Kernel API Key", "Found multiple non-deleted Kernel API keys with exact name "+name+". Configure id instead.") + return apiKeyModel{}, diags + } +} + +func (d *apiKeyDataSource) findByName(ctx context.Context, name string, diags *diag.Diagnostics) (*kernel.APIKey, int) { + var match *kernel.APIKey + count := 0 + offset := int64(0) + seen := map[string]bool{} + + for { + page, err := d.client.ListAPIKeyPage(ctx, name, offset) + if err != nil { + diags.AddError("Lookup Kernel API Key", err.Error()) + return nil, 0 + } + for _, key := range page.Items { + if key.Name != name || seen[key.ID] { + continue + } + if !datasources.ValidResponseString(key.JSON.ID.Raw(), key.JSON.ID.Valid(), key.ID) { + datasources.AddInvalidResponseField(diags, "API Key", "id") + return nil, 0 + } + if !datasources.ValidResponseString(key.JSON.Name.Raw(), key.JSON.Name.Valid(), key.Name) { + datasources.AddInvalidResponseField(diags, "API Key", "name") + return nil, 0 + } + seen[key.ID] = true + count++ + if match == nil { + matched := key + match = &matched + } + } + if !page.HasNextPage { + break + } + offset = page.NextOffset + } + + return match, count +} + +func flattenAPIKey(key kernel.APIKey) (apiKeyModel, diag.Diagnostics) { + var diags diag.Diagnostics + if !datasources.ValidResponseString(key.JSON.ID.Raw(), key.JSON.ID.Valid(), key.ID) { + datasources.AddInvalidResponseField(&diags, "API Key", "id") + } + if !datasources.ValidResponseString(key.JSON.Name.Raw(), key.JSON.Name.Valid(), key.Name) { + datasources.AddInvalidResponseField(&diags, "API Key", "name") + } + if !datasources.ValidResponseString(key.JSON.MaskedKey.Raw(), key.JSON.MaskedKey.Valid(), key.MaskedKey) { + datasources.AddInvalidResponseField(&diags, "API Key", "masked_key") + } + if !datasources.ValidResponseTime(key.JSON.CreatedAt.Raw(), key.JSON.CreatedAt.Valid(), key.CreatedAt) { + datasources.AddInvalidResponseField(&diags, "API Key", "created_at") + } + if strings.TrimSpace(key.JSON.DeletedAt.Raw()) != "null" || !key.DeletedAt.IsZero() { + datasources.AddInvalidResponseField(&diags, "API Key", "deleted_at") + } + if !datasources.FieldPresent(key.JSON.CreatedBy.Raw()) || !key.JSON.CreatedBy.Valid() { + datasources.AddInvalidResponseField(&diags, "API Key", "created_by") + } + if !datasources.ValidResponseString(key.CreatedBy.JSON.ID.Raw(), key.CreatedBy.JSON.ID.Valid(), key.CreatedBy.ID) { + datasources.AddInvalidResponseField(&diags, "API Key", "created_by.id") + } + if !datasources.ValidResponseString(key.CreatedBy.JSON.Email.Raw(), key.CreatedBy.JSON.Email.Valid(), key.CreatedBy.Email) { + datasources.AddInvalidResponseField(&diags, "API Key", "created_by.email") + } + + expiresAt := nullableAPIKeyTime(key.JSON.ExpiresAt.Raw(), key.JSON.ExpiresAt.Valid(), key.ExpiresAt, "expires_at", &diags) + projectID := nullableAPIKeyString(key.JSON.ProjectID.Raw(), key.JSON.ProjectID.Valid(), key.ProjectID, "project_id", &diags) + projectName := nullableAPIKeyString(key.JSON.ProjectName.Raw(), key.JSON.ProjectName.Valid(), key.ProjectName, "project_name", &diags) + createdByName := nullableAPIKeyString(key.CreatedBy.JSON.Name.Raw(), key.CreatedBy.JSON.Name.Valid(), key.CreatedBy.Name, "created_by.name", &diags) + if diags.HasError() { + return apiKeyModel{}, diags + } + + return apiKeyModel{ + ID: types.StringValue(key.ID), + Name: types.StringValue(key.Name), + MaskedKey: types.StringValue(key.MaskedKey), + ProjectID: projectID, + ProjectName: projectName, + CreatedAt: types.StringValue(key.CreatedAt.Format(time.RFC3339Nano)), + ExpiresAt: expiresAt, + CreatedByID: types.StringValue(key.CreatedBy.ID), + CreatedByEmail: types.StringValue(key.CreatedBy.Email), + CreatedByName: createdByName, + }, diags +} + +func nullableAPIKeyString(raw string, valid bool, value, field string, diags *diag.Diagnostics) types.String { + if raw == "" { + datasources.AddInvalidResponseField(diags, "API Key", field) + return types.StringNull() + } + if strings.TrimSpace(raw) == "null" { + if value != "" { + datasources.AddInvalidResponseField(diags, "API Key", field) + } + return types.StringNull() + } + + var decoded string + if !valid || json.Unmarshal([]byte(raw), &decoded) != nil || decoded != value { + datasources.AddInvalidResponseField(diags, "API Key", field) + return types.StringNull() + } + if value == "" { + return types.StringNull() + } + return types.StringValue(value) +} + +func nullableAPIKeyTime(raw string, valid bool, value time.Time, field string, diags *diag.Diagnostics) types.String { + if raw == "" { + datasources.AddInvalidResponseField(diags, "API Key", field) + return types.StringNull() + } + if strings.TrimSpace(raw) == "null" { + if !value.IsZero() { + datasources.AddInvalidResponseField(diags, "API Key", field) + } + return types.StringNull() + } + if !datasources.ValidResponseTime(raw, valid, value) { + datasources.AddInvalidResponseField(diags, "API Key", field) + return types.StringNull() + } + return types.StringValue(value.Format(time.RFC3339Nano)) +} diff --git a/internal/datasources/apikey/datasource_acc_test.go b/internal/datasources/apikey/datasource_acc_test.go new file mode 100644 index 0000000..e8fffdb --- /dev/null +++ b/internal/datasources/apikey/datasource_acc_test.go @@ -0,0 +1,96 @@ +package apikey_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/acctest" +) + +func TestAccAPIKeyDataSourceIDAndExactName(t *testing.T) { + if os.Getenv("TF_ACC") != "1" { + t.Skip("set TF_ACC=1 to run Terraform acceptance tests") + } + acctest.PreCheck(t) + + projectID := os.Getenv(acctest.EnvProjectID) + if projectID == "" { + t.Fatalf("%s must be set for the project-scoped API key fixture", acctest.EnvProjectID) + } + name := acctest.UniqueName(t, "api-key-data") + client := acctest.ClientFromEnv() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + created, err := client.CreateAPIKey(ctx, kernel.APIKeyNewParams{ + Name: name, + ProjectID: kernel.String(projectID), + }) + cancel() + if err != nil { + t.Fatalf("create Kernel API key fixture: %v", err) + } + if created == nil || created.ID == "" { + t.Fatal("Kernel returned an empty API key fixture response") + } + id := created.ID + + // Cleanups run last-in-first-out: delete first, then prove ordinary Get no + // longer returns the soft-deleted fixture. + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, err := client.GetAPIKey(ctx, id) + if err == nil || !acctest.IsNotFound(err) { + t.Errorf("verify Kernel API key fixture %s deletion: %v", id, err) + } + }) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := client.DeleteAPIKey(ctx, id); err != nil && !acctest.IsNotFound(err) { + t.Errorf("delete Kernel API key fixture %s: %v", id, err) + } + }) + + config := testAccAPIKeyDataSourceConfig(id, name) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("data.kernel_api_key.by_id", "id", "data.kernel_api_key.by_name", "id"), + resource.TestCheckResourceAttrPair("data.kernel_api_key.by_id", "masked_key", "data.kernel_api_key.by_name", "masked_key"), + resource.TestCheckResourceAttr("data.kernel_api_key.by_id", "id", id), + resource.TestCheckResourceAttr("data.kernel_api_key.by_id", "name", name), + resource.TestCheckResourceAttr("data.kernel_api_key.by_id", "project_id", projectID), + resource.TestCheckResourceAttrSet("data.kernel_api_key.by_id", "masked_key"), + resource.TestCheckResourceAttrSet("data.kernel_api_key.by_id", "created_at"), + resource.TestCheckNoResourceAttr("data.kernel_api_key.by_id", "key"), + resource.TestCheckNoResourceAttr("data.kernel_api_key.by_id", "deleted_at"), + ), + }, + { + Config: config, + PlanOnly: true, + }, + }, + }) +} + +func testAccAPIKeyDataSourceConfig(id, name string) string { + return acctest.ProviderConfig() + fmt.Sprintf(` +data "kernel_api_key" "by_id" { + id = %[1]q +} + +data "kernel_api_key" "by_name" { + name = %[2]q +} +`, id, name) +} diff --git a/internal/datasources/apikey/datasource_test.go b/internal/datasources/apikey/datasource_test.go new file mode 100644 index 0000000..5710f70 --- /dev/null +++ b/internal/datasources/apikey/datasource_test.go @@ -0,0 +1,324 @@ +package apikey + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/packages/respjson" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ apiKeyClient = kernelclient.Clients{} + +type fakeAPIKeyClient struct { + get func(context.Context, string) (*kernel.APIKey, error) + list func(context.Context, string, int64) (kernelclient.APIKeyPage, error) +} + +func (f fakeAPIKeyClient) GetAPIKey(ctx context.Context, id string) (*kernel.APIKey, error) { + if f.get == nil { + return nil, errors.New("unexpected API key get") + } + return f.get(ctx, id) +} + +func (f fakeAPIKeyClient) ListAPIKeyPage(ctx context.Context, query string, offset int64) (kernelclient.APIKeyPage, error) { + if f.list == nil { + return kernelclient.APIKeyPage{}, errors.New("unexpected API key list") + } + return f.list(ctx, query, offset) +} + +func TestDataSourceMetadataAndSchema(t *testing.T) { + t.Parallel() + + ds := NewDataSource() + var metadata datasource.MetadataResponse + ds.Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_api_key" { + t.Fatalf("TypeName = %q, want kernel_api_key", metadata.TypeName) + } + + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "masked_key", "project_id", "project_name", "created_at", "expires_at", "created_by_id", "created_by_email", "created_by_name"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("schema missing %s", name) + } + } + for _, name := range []string{"key", "deleted_at", "rotation_keeper", "days_to_expire"} { + if _, ok := schema.Schema.Attributes[name]; ok { + t.Fatalf("schema must not expose %s", name) + } + } + masked, ok := schema.Schema.Attributes["masked_key"].(dschema.StringAttribute) + if !ok || !masked.Sensitive || !masked.Computed { + t.Fatalf("masked_key schema = %#v, want sensitive computed string", schema.Schema.Attributes["masked_key"]) + } +} + +func TestReadByIDReturnsMaskedMetadata(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeAPIKeyClient{ + get: func(ctx context.Context, id string) (*kernel.APIKey, error) { + key := apiKeyForTest(t, id, "deploy") + return &key, nil + }, + }) + state, diags := ds.read(context.Background(), apiKeyModel{ID: types.StringValue("key-1"), Name: types.StringNull()}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "key-1" || state.Name.ValueString() != "deploy" { + t.Fatalf("identity = %q/%q", state.ID.ValueString(), state.Name.ValueString()) + } + if state.MaskedKey.ValueString() != "kern****test" { + t.Fatalf("masked_key = %q", state.MaskedKey.ValueString()) + } + if state.ProjectID.ValueString() != "project-1" || state.ProjectName.ValueString() != "Project" { + t.Fatalf("project = %q/%q", state.ProjectID.ValueString(), state.ProjectName.ValueString()) + } + if state.CreatedByID.ValueString() != "user-1" || state.CreatedByEmail.ValueString() != "user@example.com" { + t.Fatalf("creator = %q/%q", state.CreatedByID.ValueString(), state.CreatedByEmail.ValueString()) + } +} + +func TestReadByNameScansPagesAndDeduplicatesByID(t *testing.T) { + t.Parallel() + + target := apiKeyForTest(t, "key-target", "deploy") + ds := newDataSourceWithClient(fakeAPIKeyClient{ + list: listAPIKeyPages(t, "deploy", map[int64]kernelclient.APIKeyPage{ + 0: { + Items: []kernel.APIKey{apiKeyForTest(t, "fuzzy", "deploy-old"), target}, + NextOffset: 100, + HasNextPage: true, + }, + 100: {Items: []kernel.APIKey{target}}, + }), + }) + state, diags := ds.read(context.Background(), apiKeyModel{ID: types.StringNull(), Name: types.StringValue("deploy")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if state.ID.ValueString() != "key-target" { + t.Fatalf("id = %q, want key-target", state.ID.ValueString()) + } +} + +func TestReadByNameDiagnosesMissingAndAmbiguousMatches(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + page kernelclient.APIKeyPage + want string + }{ + "missing": { + page: kernelclient.APIKeyPage{Items: []kernel.APIKey{apiKeyForTest(t, "fuzzy", "deploy-old")}}, + want: "No non-deleted Kernel API key", + }, + "ambiguous": { + page: kernelclient.APIKeyPage{Items: []kernel.APIKey{ + apiKeyForTest(t, "key-1", "deploy"), + apiKeyForTest(t, "key-2", "deploy"), + }}, + want: "multiple non-deleted Kernel API keys", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + ds := newDataSourceWithClient(fakeAPIKeyClient{ + list: listAPIKeyPages(t, "deploy", map[int64]kernelclient.APIKeyPage{0: test.page}), + }) + _, diags := ds.read(context.Background(), apiKeyModel{ID: types.StringNull(), Name: types.StringValue("deploy")}) + if !diags.HasError() || !diagnosticsContain(diags, test.want) { + t.Fatalf("diagnostics = %v, want %q", diags, test.want) + } + }) + } +} + +func TestReadRejectsSelectorsAndClientFailures(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + client apiKeyClient + config apiKeyModel + want string + }{ + "missing client": { + config: apiKeyModel{ID: types.StringValue("key-1"), Name: types.StringNull()}, + want: "Missing Kernel Client", + }, + "missing selector": { + client: fakeAPIKeyClient{}, + config: apiKeyModel{ID: types.StringNull(), Name: types.StringNull()}, + want: "Missing API Key Selector", + }, + "conflicting selector": { + client: fakeAPIKeyClient{}, + config: apiKeyModel{ID: types.StringValue("key-1"), Name: types.StringValue("deploy")}, + want: "Conflicting API Key Selectors", + }, + "get error": { + client: fakeAPIKeyClient{get: func(context.Context, string) (*kernel.APIKey, error) { + return nil, errors.New("read failed") + }}, + config: apiKeyModel{ID: types.StringValue("key-1"), Name: types.StringNull()}, + want: "read failed", + }, + "empty response": { + client: fakeAPIKeyClient{get: func(context.Context, string) (*kernel.APIKey, error) { return nil, nil }}, + config: apiKeyModel{ID: types.StringValue("key-1"), Name: types.StringNull()}, + want: "empty API key response", + }, + "id mismatch": { + client: fakeAPIKeyClient{get: func(context.Context, string) (*kernel.APIKey, error) { + key := apiKeyForTest(t, "key-other", "deploy") + return &key, nil + }}, + config: apiKeyModel{ID: types.StringValue("key-1"), Name: types.StringNull()}, + want: "ID Mismatch", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + ds := newDataSourceWithClient(test.client) + _, diags := ds.read(context.Background(), test.config) + if !diags.HasError() || !diagnosticsContain(diags, test.want) { + t.Fatalf("diagnostics = %v, want %q", diags, test.want) + } + }) + } +} + +func TestFlattenAPIKeyRejectsMalformedOrDeletedMetadata(t *testing.T) { + t.Parallel() + + valid := apiKeyPayload("key-1", "deploy") + for _, field := range []string{"id", "name", "masked_key", "created_at", "created_by", "deleted_at", "expires_at", "project_id", "project_name"} { + t.Run("missing "+field, func(t *testing.T) { + t.Parallel() + payload := cloneMap(valid) + delete(payload, field) + _, diags := flattenAPIKey(apiKeyFromPayload(t, payload)) + if !diags.HasError() { + t.Fatalf("expected diagnostics for missing %s", field) + } + }) + } + + t.Run("deleted key", func(t *testing.T) { + t.Parallel() + payload := cloneMap(valid) + payload["deleted_at"] = "2026-07-11T13:00:00Z" + _, diags := flattenAPIKey(apiKeyFromPayload(t, payload)) + if !diags.HasError() || !diagnosticsContain(diags, "deleted_at") { + t.Fatalf("diagnostics = %v, want deleted_at", diags) + } + }) + + t.Run("invalid creator email", func(t *testing.T) { + t.Parallel() + key := apiKeyFromPayload(t, valid) + key.CreatedBy.Email = "" + key.CreatedBy.JSON.Email = respjson.NewInvalidField("123") + _, diags := flattenAPIKey(key) + if !diags.HasError() || !diagnosticsContain(diags, "created_by.email") { + t.Fatalf("diagnostics = %v, want created_by.email", diags) + } + }) +} + +func TestFlattenAPIKeyNormalizesNullableMetadata(t *testing.T) { + t.Parallel() + + payload := apiKeyPayload("key-1", "org-wide") + payload["expires_at"] = nil + payload["project_id"] = nil + payload["project_name"] = nil + payload["created_by"] = map[string]any{"id": "user-1", "email": "user@example.com", "name": nil} + state, diags := flattenAPIKey(apiKeyFromPayload(t, payload)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.ExpiresAt.IsNull() || !state.ProjectID.IsNull() || !state.ProjectName.IsNull() || !state.CreatedByName.IsNull() { + t.Fatalf("nullable metadata = %v/%v/%v/%v", state.ExpiresAt, state.ProjectID, state.ProjectName, state.CreatedByName) + } +} + +func listAPIKeyPages(t *testing.T, query string, pages map[int64]kernelclient.APIKeyPage) func(context.Context, string, int64) (kernelclient.APIKeyPage, error) { + t.Helper() + return func(ctx context.Context, gotQuery string, offset int64) (kernelclient.APIKeyPage, error) { + if gotQuery != query { + t.Fatalf("query = %q, want %q", gotQuery, query) + } + page, ok := pages[offset] + if !ok { + t.Fatalf("unexpected offset %d", offset) + } + return page, nil + } +} + +func apiKeyForTest(t *testing.T, id, name string) kernel.APIKey { + t.Helper() + return apiKeyFromPayload(t, apiKeyPayload(id, name)) +} + +func apiKeyPayload(id, name string) map[string]any { + return map[string]any{ + "id": id, + "name": name, + "masked_key": "kern****test", + "created_at": "2026-07-11T12:00:00Z", + "created_by": map[string]any{"id": "user-1", "email": "user@example.com", "name": "User"}, + "deleted_at": nil, + "expires_at": "2027-07-11T12:00:00Z", + "project_id": "project-1", + "project_name": "Project", + } +} + +func apiKeyFromPayload(t *testing.T, payload map[string]any) kernel.APIKey { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal API key: %v", err) + } + var key kernel.APIKey + if err := json.Unmarshal(raw, &key); err != nil { + t.Fatalf("unmarshal API key: %v", err) + } + return key +} + +func cloneMap(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func diagnosticsContain(diags diag.Diagnostics, want string) bool { + for _, diagnostic := range diags { + if strings.Contains(diagnostic.Summary(), want) || strings.Contains(diagnostic.Detail(), want) { + return true + } + } + return false +} diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index 8bdc316..b33290c 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -24,6 +24,7 @@ type Page[T any] struct { type ProfilePage = Page[kernel.Profile] type ProxyPage = Page[kernel.ProxyListResponse] type AppPage = Page[kernel.AppListResponse] +type APIKeyPage = Page[kernel.APIKey] // Config configures the shared Kernel API clients. ProjectID is a default // only; the client never applies it implicitly. @@ -37,6 +38,7 @@ type Config struct { type Clients struct { defaultProjectID string projects kernel.ProjectService + apiKeys kernel.APIKeyService profiles kernel.ProfileService proxies kernel.ProxyService apps kernel.AppService @@ -68,6 +70,7 @@ func New(config Config, opts ...Option) Clients { return Clients{ defaultProjectID: config.ProjectID, projects: kernel.NewProjectService(requestOpts...), + apiKeys: kernel.NewAPIKeyService(requestOpts...), profiles: kernel.NewProfileService(requestOpts...), proxies: kernel.NewProxyService(requestOpts...), apps: kernel.NewAppService(requestOpts...), @@ -101,6 +104,48 @@ func (c Clients) DeleteProject(ctx context.Context, id string) error { return c.projects.Delete(ctx, id, noMutationRetries()) } +func (c Clients) GetAPIKey(ctx context.Context, id string) (*kernel.APIKey, error) { + return c.apiKeys.Get(ctx, id, kernel.APIKeyGetParams{}) +} + +func (c Clients) ListAPIKeyPage(ctx context.Context, query string, offset int64) (APIKeyPage, error) { + var raw *http.Response + params := kernel.APIKeyListParams{ + Query: kernel.String(query), + Limit: kernel.Int(nameLookupLimit), + Status: kernel.APIKeyListParamsStatusActive, + } + if offset > 0 { + params.Offset = kernel.Int(offset) + } + + page, err := c.apiKeys.List(ctx, params, option.WithResponseInto(&raw)) + if err != nil { + return APIKeyPage{}, err + } + if page == nil { + return APIKeyPage{}, fmt.Errorf("Kernel returned an empty API key list response") + } + + next, ok, err := lookupNextOffset(raw, offset, "API key") + if err != nil { + return APIKeyPage{}, err + } + return APIKeyPage{ + Items: page.Items, + NextOffset: next, + HasNextPage: ok, + }, nil +} + +func (c Clients) CreateAPIKey(ctx context.Context, params kernel.APIKeyNewParams) (*kernel.CreatedAPIKey, error) { + return c.apiKeys.New(ctx, params, noMutationRetries()) +} + +func (c Clients) DeleteAPIKey(ctx context.Context, id string) error { + return c.apiKeys.Delete(ctx, id, noMutationRetries()) +} + // The remaining methods are project-scoped and take the resolved project // for each call. diff --git a/internal/kernelclient/client_test.go b/internal/kernelclient/client_test.go index 1e40d73..076837d 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -305,6 +305,87 @@ func TestListAppPageUsesExactFiltersAndPagination(t *testing.T) { } } +func TestListAPIKeyPageUsesActiveQueryAndPagination(t *testing.T) { + t.Parallel() + + var requests []capturedRequest + clients := New(Config{ + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "must-not-scope-org-endpoint", + }, WithHTTPClient(recordingHTTPClientWithHeaders(&requests, func(req *http.Request) (string, http.Header) { + if req.URL.Path != "/org/api_keys" { + t.Fatalf("path = %q, want /org/api_keys", req.URL.Path) + } + if got, want := req.URL.Query().Get("query"), "deploy"; got != want { + t.Fatalf("query = %q, want %q", got, want) + } + if got, want := req.URL.Query().Get("status"), "active"; got != want { + t.Fatalf("status = %q, want %q", got, want) + } + if got, want := req.URL.Query().Get("limit"), "100"; got != want { + t.Fatalf("limit = %q, want %q", got, want) + } + if req.URL.Query().Get("offset") == "" { + return apiKeyListPage("key-1", "deploy"), http.Header{"X-Next-Offset": []string{"100"}, "X-Has-More": []string{"true"}} + } + if got, want := req.URL.Query().Get("offset"), "100"; got != want { + t.Fatalf("offset = %q, want %q", got, want) + } + return apiKeyListPage("key-2", "deploy"), http.Header{"X-Has-More": []string{"false"}} + }))) + + page, err := clients.ListAPIKeyPage(context.Background(), "deploy", 0) + if err != nil { + t.Fatalf("ListAPIKeyPage returned error: %v", err) + } + if len(page.Items) != 1 || page.Items[0].ID != "key-1" || !page.HasNextPage || page.NextOffset != 100 { + t.Fatalf("first page = %#v", page) + } + page, err = clients.ListAPIKeyPage(context.Background(), "deploy", 100) + if err != nil { + t.Fatalf("ListAPIKeyPage second page returned error: %v", err) + } + if len(page.Items) != 1 || page.Items[0].ID != "key-2" || page.HasNextPage { + t.Fatalf("second page = %#v", page) + } + for index, request := range requests { + if request.ProjectID != "" { + t.Fatalf("request %d project = %q, want unscoped org endpoint", index, request.ProjectID) + } + } +} + +func TestGetAPIKeyUsesOrganizationScope(t *testing.T) { + t.Parallel() + + var requests []capturedRequest + clients := New(Config{ + APIKey: "test-api-key", + BaseURL: "https://api.example", + ProjectID: "must-not-scope-org-endpoint", + }, WithHTTPClient(recordingHTTPClient(&requests, func(req *http.Request) string { + if req.URL.Path != "/org/api_keys/key-1" { + t.Fatalf("path = %q, want /org/api_keys/key-1", req.URL.Path) + } + if got := req.URL.Query().Get("include_deleted"); got != "" { + t.Fatalf("include_deleted = %q, want omitted", got) + } + return apiKeyJSON("key-1", "deploy") + }))) + + key, err := clients.GetAPIKey(context.Background(), "key-1") + if err != nil { + t.Fatalf("GetAPIKey returned error: %v", err) + } + if key == nil || key.ID != "key-1" { + t.Fatalf("GetAPIKey = %#v, want key-1", key) + } + if got := requests[0].ProjectID; got != "" { + t.Fatalf("project header = %q, want organization-scoped request", got) + } +} + func TestGetExtensionResolvesByIDOrNameWithinProject(t *testing.T) { t.Parallel() @@ -401,6 +482,21 @@ func TestMutationsDisableSDKRetriesAndUseExpectedScope(t *testing.T) { return clients.DeleteProject(ctx, "project_123") }, }, + "API key create": { + method: http.MethodPost, + path: "/org/api_keys", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.CreateAPIKey(ctx, kernel.APIKeyNewParams{Name: "Fixture"}) + return err + }, + }, + "API key delete": { + method: http.MethodDelete, + path: "/org/api_keys/key_123", + call: func(ctx context.Context, clients Clients) error { + return clients.DeleteAPIKey(ctx, "key_123") + }, + }, "profile create": { method: http.MethodPost, path: "/profiles", @@ -609,10 +705,26 @@ func TestClientsExposeDeploymentReadOnly(t *testing.T) { } } +func TestClientsDoNotExposeAPIKeyRotation(t *testing.T) { + t.Parallel() + + if _, ok := reflect.TypeOf(Clients{}).MethodByName("RotateAPIKey"); ok { + t.Fatal("Clients exposes unsafe API key rotation") + } +} + func appListPage(id string) string { return `[{"id":"` + id + `","app_name":"demo","version":"v1","region":"aws.us-east-1a","deployment":"deployment-1","actions":[],"env_vars":{}}]` } +func apiKeyListPage(id, name string) string { + return `[` + apiKeyJSON(id, name) + `]` +} + +func apiKeyJSON(id, name string) string { + return `{"id":"` + id + `","name":"` + name + `","masked_key":"kern****test","created_at":"2026-01-01T00:00:00Z","created_by":{"id":"user-1","email":"user@example.com","name":"User"},"deleted_at":null,"expires_at":null,"project_id":null,"project_name":null}` +} + type capturedRequest struct { Method string Path string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 301f828..f7a7452 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" + apikeydatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/apikey" appdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/app" browserpooldatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/browserpool" deploymentdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/deployment" @@ -94,6 +95,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ appdatasource.NewDataSource, + apikeydatasource.NewDataSource, browserpooldatasource.NewDataSource, deploymentdatasource.NewDataSource, projectdatasource.NewDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 79783ee..c2db29d 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -62,8 +62,8 @@ func TestProviderRegistersDataSources(t *testing.T) { p := provider.New("test")() dataSources := p.DataSources(context.Background()) - if len(dataSources) != 7 { - t.Fatalf("DataSources length = %d, want 7", len(dataSources)) + if len(dataSources) != 8 { + t.Fatalf("DataSources length = %d, want 8", len(dataSources)) } got := make(map[string]bool, len(dataSources)) @@ -77,7 +77,7 @@ func TestProviderRegistersDataSources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_app", "kernel_browser_pool", "kernel_deployment", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { + for _, want := range []string{"kernel_api_key", "kernel_app", "kernel_browser_pool", "kernel_deployment", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { if !got[want] { t.Fatalf("missing data source %s; got %v", want, got) }