From 32752b94a535929aa4c3fdea06c5c412e565e3b4 Mon Sep 17 00:00:00 2001 From: shulmanbrent Date: Thu, 5 Nov 2026 14:31:23 -0500 Subject: [PATCH 1/2] feat: add kernel_audit_log_export_destination resource Manage the org-scoped S3 audit-log export destination: bucket, region, prefix, role_arn, optional kms_key_id and status, with computed external_id and kernel_role_arn for the role's trust policy. Delivery health fields stay out of state. Import by canonical ID. --- .github/workflows/acceptance.yml | 18 + README.md | 4 + docs/acceptance.md | 15 +- docs/architecture.md | 3 +- docs/release.md | 4 +- .../resources/audit_log_export_destination.md | 46 ++ examples/README.md | 1 + examples/audit-log-export-destination/main.tf | 48 ++ .../import.sh | 1 + internal/acctest/acctest.go | 40 ++ internal/kernelclient/client.go | 20 + internal/kernelclient/client_test.go | 47 ++ internal/provider/provider.go | 2 + internal/provider/provider_test.go | 6 +- .../auditlogexportdestination/convert.go | 95 ++++ .../auditlogexportdestination/convert_test.go | 205 ++++++++ .../auditlogexportdestination/model.go | 17 + .../auditlogexportdestination/resource.go | 262 ++++++++++ .../resource_acc_test.go | 158 ++++++ .../resource_test.go | 485 ++++++++++++++++++ .../auditlogexportdestination/schema.go | 111 ++++ .../auditlogexportdestination/schema_test.go | 79 +++ 22 files changed, 1659 insertions(+), 8 deletions(-) create mode 100644 docs/resources/audit_log_export_destination.md create mode 100644 examples/audit-log-export-destination/main.tf create mode 100644 examples/resources/kernel_audit_log_export_destination/import.sh create mode 100644 internal/resources/auditlogexportdestination/convert.go create mode 100644 internal/resources/auditlogexportdestination/convert_test.go create mode 100644 internal/resources/auditlogexportdestination/model.go create mode 100644 internal/resources/auditlogexportdestination/resource.go create mode 100644 internal/resources/auditlogexportdestination/resource_acc_test.go create mode 100644 internal/resources/auditlogexportdestination/resource_test.go create mode 100644 internal/resources/auditlogexportdestination/schema.go create mode 100644 internal/resources/auditlogexportdestination/schema_test.go diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 60d07fe..dd6b74f 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -30,6 +30,10 @@ jobs: - name: Telemetry destination package: ./internal/resources/telemetrydestination project_id_required: false + - name: Audit log export destination + package: ./internal/resources/auditlogexportdestination + project_id_required: false + audit_export_required: true - name: Project data source package: ./internal/datasources/project project_id_required: true @@ -64,6 +68,17 @@ jobs: run: | : "${KERNEL_PROJECT_ID:?Set the KERNEL_PROJECT_ID repository secret before running browser-pool acceptance tests.}" + - name: Check audit log export secrets + if: matrix.audit_export_required + env: + KERNEL_AUDIT_EXPORT_BUCKET: ${{ secrets.KERNEL_AUDIT_EXPORT_BUCKET }} + KERNEL_AUDIT_EXPORT_REGION: ${{ secrets.KERNEL_AUDIT_EXPORT_REGION }} + KERNEL_AUDIT_EXPORT_ROLE_ARN: ${{ secrets.KERNEL_AUDIT_EXPORT_ROLE_ARN }} + run: | + : "${KERNEL_AUDIT_EXPORT_BUCKET:?Set the KERNEL_AUDIT_EXPORT_BUCKET repository secret before running audit log export acceptance tests.}" + : "${KERNEL_AUDIT_EXPORT_REGION:?Set the KERNEL_AUDIT_EXPORT_REGION repository secret before running audit log export acceptance tests.}" + : "${KERNEL_AUDIT_EXPORT_ROLE_ARN:?Set the KERNEL_AUDIT_EXPORT_ROLE_ARN repository secret before running audit log export acceptance tests.}" + - name: Checkout uses: actions/checkout@v6 @@ -85,6 +100,9 @@ jobs: KERNEL_BASE_URL: ${{ secrets.KERNEL_BASE_URL }} KERNEL_PROJECT_ID: ${{ matrix.project_id_required && secrets.KERNEL_PROJECT_ID || '' }} KERNEL_ALT_PROJECT_ID: ${{ matrix.project_id_required && secrets.KERNEL_ALT_PROJECT_ID || '' }} + KERNEL_AUDIT_EXPORT_BUCKET: ${{ matrix.audit_export_required && secrets.KERNEL_AUDIT_EXPORT_BUCKET || '' }} + KERNEL_AUDIT_EXPORT_REGION: ${{ matrix.audit_export_required && secrets.KERNEL_AUDIT_EXPORT_REGION || '' }} + KERNEL_AUDIT_EXPORT_ROLE_ARN: ${{ matrix.audit_export_required && secrets.KERNEL_AUDIT_EXPORT_ROLE_ARN || '' }} run: | if [ -z "$KERNEL_BASE_URL" ]; then unset KERNEL_BASE_URL diff --git a/README.md b/README.md index 5ac3229..8b4088b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Resources: - `kernel_browser_pool` - `kernel_project` - `kernel_telemetry_destination` +- `kernel_audit_log_export_destination` Data sources: @@ -30,6 +31,7 @@ Import: - `kernel_browser_pool` by canonical browser pool ID - `kernel_project` by canonical project ID - `kernel_telemetry_destination` by canonical telemetry destination ID +- `kernel_audit_log_export_destination` by canonical audit log export destination ID ## Not Supported @@ -39,6 +41,7 @@ The provider intentionally does not manage: - acquire, release, or flush - app invocation - logs, screenshots, or live view +- audit log search, download, or destination test deliveries - runtime status or standby state - force-release or recovery operations - API key, profile, proxy, or extension resources @@ -87,6 +90,7 @@ terraform import kernel_browser_pool.example terraform import kernel_browser_pool.example / terraform import kernel_project.example terraform import kernel_telemetry_destination.example +terraform import kernel_audit_log_export_destination.example ``` Kernel never returns telemetry destination header values, so an imported diff --git a/docs/acceptance.md b/docs/acceptance.md index 2ef9146..91f1975 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -4,7 +4,7 @@ This document is the live-API release gate for the provider's selected public surface. Unit tests remain the fast default. Acceptance tests run after changes reach `main`, through explicit local opt-in, or by manual workflow dispatch. -The selected surface contains three managed resources and four read-only data +The selected surface contains four managed resources and four read-only data sources. It does not claim coverage for future or unregistered Kernel objects. ## Gate Rules @@ -29,6 +29,9 @@ export KERNEL_API_KEY=... export KERNEL_PROJECT_ID=... export KERNEL_ALT_PROJECT_ID=... # optional second project export KERNEL_BASE_URL=... # optional non-production API +export KERNEL_AUDIT_EXPORT_BUCKET=... # audit log export destination only +export KERNEL_AUDIT_EXPORT_REGION=... +export KERNEL_AUDIT_EXPORT_ROLE_ARN=... ``` `KERNEL_PROJECT_ID` is required for the browser-pool resource and all four data @@ -36,6 +39,12 @@ sources. The project and telemetry destination resources are organization-scoped and do not require it; the telemetry destination test needs an API key that is not bound to a project. +The audit log export destination resource is organization-scoped too. Its test +needs an API key that is not bound to a project, on a Kernel plan that includes +audit logs, plus an S3 bucket, its region, and a role that Kernel can assume. The +test skips locally when those three variables are unset; the workflow fails +instead. + ## Matrix | Surface | Package | Live scenario | @@ -43,6 +52,7 @@ not bound to a project. | `kernel_project` resource | `./internal/resources/project` | Create, rename with stable ID, no-drift plan, canonical-ID import, post-import no drift, delete, and HTTP 404 verification. | | `kernel_browser_pool` resource | `./internal/resources/browserpool` | Create, durable update with stable ID, opt into rebuilding and acquire an idle browser after a configuration change, no-drift plan, provider-default and explicit project scope, bare and project-qualified import, non-force delete, and HTTP 404 verification. | | `kernel_telemetry_destination` resource | `./internal/resources/telemetrydestination` | Create with headers, update name, endpoint, description, and headers with stable ID, remove all headers, verify header names through the API, no-drift plan, canonical-ID import, delete, and HTTP 404 verification. | +| `kernel_audit_log_export_destination` resource | `./internal/resources/auditlogexportdestination` | Create with a unique prefix, verify computed `external_id` and `kernel_role_arn`, update the prefix and pause with stable ID, verify the paused status through the API, no-drift plan, canonical-ID import, delete, and HTTP 404 verification. | | `kernel_project` data source | `./internal/datasources/project` | Create a unique project fixture, read it by ID and exact name, read the provider-default project, verify durable metadata and no drift, then delete and require coded `not_found`. | | `kernel_profile` data source | `./internal/datasources/profile` | Create a durable profile fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable metadata and no drift, then delete and require coded `not_found`. | | `kernel_proxy` data source | `./internal/datasources/proxy` | Create a managed datacenter proxy fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable masked metadata and no drift, then delete and require coded `not_found`. | @@ -65,13 +75,14 @@ Run packages independently for fast failure isolation: go test -count=1 -timeout=30m -v ./internal/resources/project -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/browserpool -run TestAcc go test -count=1 -timeout=30m -v ./internal/resources/telemetrydestination -run TestAcc +go test -count=1 -timeout=30m -v ./internal/resources/auditlogexportdestination -run TestAcc go test -count=1 -timeout=30m -v ./internal/datasources/project -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/extension -run TestAcc ``` -The `Acceptance` workflow runs the same seven packages as separate matrix jobs +The `Acceptance` workflow runs the same eight packages as separate matrix jobs with `fail-fast: false` after changes reach `main` and on manual dispatch. ## Outside The Selected Surface diff --git a/docs/architecture.md b/docs/architecture.md index de1a16e..d3fac50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,8 @@ These are intentionally not Terraform resources or actions: - logs, screenshots, live view, or telemetry streams - runtime counters, standby state, lease state, or session state - force-release, recovery, or managed runtime browser updates -- billing, organization membership, audit-log, managed-auth, or internal administration resources +- billing, organization membership, managed-auth, or internal administration resources +- audit-log search, download, or destination test deliveries; `kernel_audit_log_export_destination` manages only the durable export configuration - standalone secrets without a dedicated durable secrets API `force_destroy` for browser pools is deferred beyond the initial v1 scope unless a demonstrated workflow justifies a later architecture amendment. Terraform should not terminate leased runtime work as ordinary durable-resource cleanup. diff --git a/docs/release.md b/docs/release.md index 9de640a..cc23d1c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -19,9 +19,9 @@ Use this checklist before publishing a Kernel Terraform provider version. checks each archive, and verifies checksum coverage. A snapshot skips signing and is not a publishable release. - Run the complete [selected-surface acceptance matrix](acceptance.md) with real credentials against the release commit. - - The `Acceptance` workflow runs all seven packages as independent matrix jobs after changes reach `main` and supports manual dispatch. Keep live tests out of pull-request CI. + - The `Acceptance` workflow runs all eight packages as independent matrix jobs after changes reach `main` and supports manual dispatch. Keep live tests out of pull-request 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, profiles, proxies, extensions, and telemetry destinations named `kernel-tf-*` that were created during the failed workflow run. + 1. In the Kernel dashboard or durable API, find projects, browser pools, profiles, proxies, extensions, and telemetry destinations named `kernel-tf-*`, and audit log export destinations whose prefix starts with `kernel-tf-`, that were created during the failed workflow run. 2. Delete leaked browser pools 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 other leaked project-scoped fixtures, then delete a leaked project only after its child resources are gone and the organization still has another active project. 4. Read each canonical ID and require the expected not-found response before considering cleanup complete. diff --git a/docs/resources/audit_log_export_destination.md b/docs/resources/audit_log_export_destination.md new file mode 100644 index 0000000..27f962a --- /dev/null +++ b/docs/resources/audit_log_export_destination.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_audit_log_export_destination Resource - Kernel" +subcategory: "" +description: |- + Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, kernel_role_arn, assumes your role_arn with external_id as the sts:ExternalId condition. Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. Replacing a destination issues a new external_id, so the role's trust policy has to be updated to match. +--- + +# kernel_audit_log_export_destination (Resource) + +Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, `kernel_role_arn`, assumes your `role_arn` with `external_id` as the `sts:ExternalId` condition. Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. Replacing a destination issues a new `external_id`, so the role's trust policy has to be updated to match. + + + + +## Schema + +### Required + +- `bucket` (String) S3 bucket name. +- `prefix` (String) Key prefix for exported objects. +- `region` (String) AWS region of the bucket. +- `role_arn` (String) ARN of the IAM role in your account that Kernel assumes to write objects. + +### Optional + +- `format` (String) Object format. Only `jsonl.gz` is supported. Changing it replaces the destination. +- `kms_key_id` (String) KMS key ID, alias, or ARN used to encrypt exported objects. Removing it clears the key. +- `status` (String) `active` or `paused`. Pausing stops delivery, and resuming starts again from the time of the resume. When omitted, Terraform does not manage the status. +- `type` (String) Destination type. Only `s3` is supported. Changing it replaces the destination. + +### Read-Only + +- `external_id` (String) Value Kernel sends as `sts:ExternalId` when it assumes `role_arn`. Require it in the role's trust policy. +- `id` (String) Unique audit-log export destination identifier. +- `kernel_role_arn` (String) Kernel role that assumes `role_arn`. Allow it as the principal in the role's trust policy. + +## 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 +terraform import kernel_audit_log_export_destination.example +``` diff --git a/examples/README.md b/examples/README.md index f749132..6cec632 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,3 +28,4 @@ override setup. - [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. - [telemetry-destination](telemetry-destination) creates an OTLP destination that browser sessions can export telemetry to. +- [audit-log-export-destination](audit-log-export-destination) exports the organization's audit logs to S3 through an IAM role that trusts Kernel. diff --git a/examples/audit-log-export-destination/main.tf b/examples/audit-log-export-destination/main.tf new file mode 100644 index 0000000..2239f35 --- /dev/null +++ b/examples/audit-log-export-destination/main.tf @@ -0,0 +1,48 @@ +terraform { + required_providers { + kernel = { + source = "kernel/kernel" + } + } +} + +provider "kernel" {} + +variable "bucket" { + type = string + description = "S3 bucket that receives the exported audit logs." +} + +variable "region" { + type = string + description = "AWS region of the bucket." +} + +variable "role_arn" { + type = string + description = "IAM role in your account that Kernel assumes to write to the bucket." +} + +resource "kernel_audit_log_export_destination" "example" { + bucket = var.bucket + region = var.region + prefix = "kernel/audit-logs/" + role_arn = var.role_arn +} + +# Attach this trust policy to var.role_arn so Kernel can assume it. +output "trust_policy" { + value = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = kernel_audit_log_export_destination.example.kernel_role_arn } + Action = "sts:AssumeRole" + Condition = { + StringEquals = { + "sts:ExternalId" = kernel_audit_log_export_destination.example.external_id + } + } + }] + }) +} diff --git a/examples/resources/kernel_audit_log_export_destination/import.sh b/examples/resources/kernel_audit_log_export_destination/import.sh new file mode 100644 index 0000000..dd81a06 --- /dev/null +++ b/examples/resources/kernel_audit_log_export_destination/import.sh @@ -0,0 +1 @@ +terraform import kernel_audit_log_export_destination.example diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index d76fe44..44091f5 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -26,6 +26,11 @@ const ( EnvProjectID = "KERNEL_PROJECT_ID" // EnvAltProjectID names a second project for cross-project override tests. EnvAltProjectID = "KERNEL_ALT_PROJECT_ID" + // The audit-log export destination test needs an S3 bucket and a role + // that trusts Kernel; it skips when these are unset. + EnvAuditExportBucket = "KERNEL_AUDIT_EXPORT_BUCKET" + EnvAuditExportRegion = "KERNEL_AUDIT_EXPORT_REGION" + EnvAuditExportRoleARN = "KERNEL_AUDIT_EXPORT_ROLE_ARN" cleanupTimeout = 2 * time.Minute ) @@ -194,6 +199,41 @@ func cleanupTelemetryDestination(t testing.TB, client telemetryDestinationCleane }) } +func CleanupAuditLogExportDestination(t testing.TB, id string) { + t.Helper() + + cleanupAuditLogExportDestination(t, ClientFromEnv(), id) +} + +type auditLogExportDestinationCleaner interface { + DeleteAuditLogExportDestination(context.Context, string) error +} + +func cleanupAuditLogExportDestination(t testing.TB, client auditLogExportDestinationCleaner, id string) { + t.Helper() + + if id == "" { + return + } + if !AcceptanceEnabled() { + t.Fatalf("%s must be set to clean up Kernel acceptance test resources", EnvAcceptance) + return + } + if os.Getenv(EnvAPIKey) == "" { + t.Fatalf("%s must be set to clean up Kernel acceptance test resources", EnvAPIKey) + return + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + + if err := client.DeleteAuditLogExportDestination(ctx, id); err != nil && !IsNotFound(err) { + t.Errorf("cleanup Kernel audit log export destination %s: %v", id, err) + } + }) +} + // CleanupProfile registers a cleanup that deletes the profile from projectID; // empty means the env-configured default project. func CleanupProfile(t testing.TB, projectID, id string) { diff --git a/internal/kernelclient/client.go b/internal/kernelclient/client.go index b313ab8..c6f7a70 100644 --- a/internal/kernelclient/client.go +++ b/internal/kernelclient/client.go @@ -41,6 +41,7 @@ type Clients struct { extensions kernel.ExtensionService browserPools kernel.BrowserPoolService telemetryDestinations kernel.TelemetryDestinationService + auditLogExports kernel.AuditLogExportDestinationService } type Option func(*clientOptions) @@ -71,6 +72,7 @@ func New(config Config, opts ...Option) Clients { extensions: kernel.NewExtensionService(requestOpts...), browserPools: kernel.NewBrowserPoolService(requestOpts...), telemetryDestinations: kernel.NewTelemetryDestinationService(requestOpts...), + auditLogExports: kernel.NewAuditLogExportDestinationService(requestOpts...), } } @@ -116,6 +118,24 @@ func (c Clients) DeleteTelemetryDestination(ctx context.Context, id string) erro return c.telemetryDestinations.Delete(ctx, id, noMutationRetries()) } +// Audit-log export destinations are org-scoped too. + +func (c Clients) CreateAuditLogExportDestination(ctx context.Context, params kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return c.auditLogExports.New(ctx, params, noMutationRetries()) +} + +func (c Clients) GetAuditLogExportDestination(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { + return c.auditLogExports.Get(ctx, id) +} + +func (c Clients) UpdateAuditLogExportDestination(ctx context.Context, id string, params kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + return c.auditLogExports.Update(ctx, id, params, noMutationRetries()) +} + +func (c Clients) DeleteAuditLogExportDestination(ctx context.Context, id string) error { + return c.auditLogExports.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 6c588e9..b8e2da5 100644 --- a/internal/kernelclient/client_test.go +++ b/internal/kernelclient/client_test.go @@ -361,6 +361,42 @@ func TestMutationsDisableSDKRetriesAndUseExpectedScope(t *testing.T) { return clients.DeleteTelemetryDestination(ctx, "dest_123") }, }, + "audit log export destination create": { + method: http.MethodPost, + path: "/audit-logs/export/destinations", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.CreateAuditLogExportDestination(ctx, kernel.AuditLogExportDestinationNewParams{ + CreateAuditLogExportDestinationRequest: kernel.CreateAuditLogExportDestinationRequestParam{ + Bucket: "audit-bucket", + Format: kernel.CreateAuditLogExportDestinationRequestFormatJSONLGz, + Prefix: "kernel/", + Region: "us-east-1", + RoleArn: "arn:aws:iam::123456789012:role/kernel-audit", + Type: kernel.CreateAuditLogExportDestinationRequestTypeS3, + }, + }) + return err + }, + }, + "audit log export destination update": { + method: http.MethodPatch, + path: "/audit-logs/export/destinations/dest_123", + call: func(ctx context.Context, clients Clients) error { + _, err := clients.UpdateAuditLogExportDestination(ctx, "dest_123", kernel.AuditLogExportDestinationUpdateParams{ + UpdateAuditLogExportDestinationRequest: kernel.UpdateAuditLogExportDestinationRequestParam{ + Prefix: kernel.String("audit/"), + }, + }) + return err + }, + }, + "audit log export destination delete": { + method: http.MethodDelete, + path: "/audit-logs/export/destinations/dest_123", + call: func(ctx context.Context, clients Clients) error { + return clients.DeleteAuditLogExportDestination(ctx, "dest_123") + }, + }, "profile create": { method: http.MethodPost, path: "/profiles", @@ -555,6 +591,17 @@ func TestClientsDoNotExposeExtensionArchiveMethods(t *testing.T) { } } +func TestClientsDoNotExposeAuditLogRuntimeMethods(t *testing.T) { + t.Parallel() + + typ := reflect.TypeOf(Clients{}) + for _, name := range []string{"ListAuditLogs", "ExportAuditLogChunk", "TestAuditLogExportDestination"} { + if _, ok := typ.MethodByName(name); ok { + t.Fatalf("Clients exposes audit-log runtime method %s", name) + } + } +} + type capturedRequest struct { Method string Path string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1a496fa..ee1dbf6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -12,6 +12,7 @@ import ( projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" "github.com/kernel/terraform-provider-kernel/internal/datasources/proxy" "github.com/kernel/terraform-provider-kernel/internal/kernelclient" + "github.com/kernel/terraform-provider-kernel/internal/resources/auditlogexportdestination" "github.com/kernel/terraform-provider-kernel/internal/resources/browserpool" projectresource "github.com/kernel/terraform-provider-kernel/internal/resources/project" "github.com/kernel/terraform-provider-kernel/internal/resources/telemetrydestination" @@ -85,6 +86,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour browserpool.NewResource, projectresource.NewResource, telemetrydestination.NewResource, + auditlogexportdestination.NewResource, } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 5a41682..3c073ac 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -34,8 +34,8 @@ func TestProviderRegistersResources(t *testing.T) { p := provider.New("test")() resources := p.Resources(context.Background()) - if len(resources) != 3 { - t.Fatalf("Resources length = %d, want 3", len(resources)) + if len(resources) != 4 { + t.Fatalf("Resources length = %d, want 4", len(resources)) } got := make(map[string]bool, len(resources)) @@ -49,7 +49,7 @@ func TestProviderRegistersResources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_telemetry_destination"} { + for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_telemetry_destination", "kernel_audit_log_export_destination"} { if !got[want] { t.Fatalf("missing resource %s; got %v", want, got) } diff --git a/internal/resources/auditlogexportdestination/convert.go b/internal/resources/auditlogexportdestination/convert.go new file mode 100644 index 0000000..0582cbd --- /dev/null +++ b/internal/resources/auditlogexportdestination/convert.go @@ -0,0 +1,95 @@ +package auditlogexportdestination + +import ( + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +func expandCreate(plan destinationModel) kernel.AuditLogExportDestinationNewParams { + request := kernel.CreateAuditLogExportDestinationRequestParam{ + Type: kernel.CreateAuditLogExportDestinationRequestType(plan.Type.ValueString()), + Format: kernel.CreateAuditLogExportDestinationRequestFormat(plan.Format.ValueString()), + Bucket: plan.Bucket.ValueString(), + Region: plan.Region.ValueString(), + Prefix: plan.Prefix.ValueString(), + RoleArn: plan.RoleARN.ValueString(), + } + if !plan.KMSKeyID.IsNull() { + request.KmsKeyID = kernel.String(plan.KMSKeyID.ValueString()) + } + return kernel.AuditLogExportDestinationNewParams{CreateAuditLogExportDestinationRequest: request} +} + +// expandUpdate returns only the fields that differ between plan and state. +func expandUpdate(plan, state destinationModel) kernel.AuditLogExportDestinationUpdateParams { + var request kernel.UpdateAuditLogExportDestinationRequestParam + if !plan.Bucket.Equal(state.Bucket) { + request.Bucket = kernel.String(plan.Bucket.ValueString()) + } + if !plan.Region.Equal(state.Region) { + request.Region = kernel.String(plan.Region.ValueString()) + } + if !plan.Prefix.Equal(state.Prefix) { + request.Prefix = kernel.String(plan.Prefix.ValueString()) + } + if !plan.RoleARN.Equal(state.RoleARN) { + request.RoleArn = kernel.String(plan.RoleARN.ValueString()) + } + if !plan.KMSKeyID.Equal(state.KMSKeyID) { + // The API clears the key when sent an empty string. + request.KmsKeyID = kernel.String(plan.KMSKeyID.ValueString()) + } + if !plan.Status.IsUnknown() && !plan.Status.Equal(state.Status) { + request.Status = kernel.UpdateAuditLogExportDestinationRequestStatus(plan.Status.ValueString()) + } + return kernel.AuditLogExportDestinationUpdateParams{UpdateAuditLogExportDestinationRequest: request} +} + +// flatten builds state from a destination response, leaving out delivery +// health fields such as last_error and consecutive_failures. +func flatten(remote kernel.AuditLogExportDestination) (destinationModel, diag.Diagnostics) { + var diags diag.Diagnostics + required := []struct { + field, value string + }{ + {"id", remote.ID}, + {"type", string(remote.Type)}, + {"format", string(remote.Format)}, + {"bucket", remote.Bucket}, + {"region", remote.Region}, + {"role_arn", remote.RoleArn}, + {"status", string(remote.Status)}, + {"external_id", remote.ExternalID}, + {"kernel_role_arn", remote.KernelRoleArn}, + } + for _, r := range required { + if r.value == "" { + diags.AddError( + "Invalid Kernel Audit Log Export Destination Response", + "Kernel returned an audit log export destination with missing field "+r.field+".", + ) + } + } + if diags.HasError() { + return destinationModel{}, diags + } + + model := destinationModel{ + ID: types.StringValue(remote.ID), + Type: types.StringValue(string(remote.Type)), + Format: types.StringValue(string(remote.Format)), + Bucket: types.StringValue(remote.Bucket), + Region: types.StringValue(remote.Region), + Prefix: types.StringValue(remote.Prefix), + RoleARN: types.StringValue(remote.RoleArn), + KMSKeyID: types.StringNull(), + Status: types.StringValue(string(remote.Status)), + ExternalID: types.StringValue(remote.ExternalID), + KernelRoleARN: types.StringValue(remote.KernelRoleArn), + } + if remote.KmsKeyID != "" { + model.KMSKeyID = types.StringValue(remote.KmsKeyID) + } + return model, diags +} diff --git a/internal/resources/auditlogexportdestination/convert_test.go b/internal/resources/auditlogexportdestination/convert_test.go new file mode 100644 index 0000000..909a194 --- /dev/null +++ b/internal/resources/auditlogexportdestination/convert_test.go @@ -0,0 +1,205 @@ +package auditlogexportdestination + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ destinationClient = kernelclient.Clients{} + +const remoteDestination = `{ + "id": "dest_123", + "type": "s3", + "format": "jsonl.gz", + "bucket": "audit-bucket", + "region": "us-east-1", + "prefix": "kernel/", + "role_arn": "arn:aws:iam::123456789012:role/kernel-audit", + "status": "active", + "external_id": "ext_123", + "kernel_role_arn": "arn:aws:iam::999999999999:role/kernel-exporter", + "consecutive_failures": 3, + "last_error": "access denied", + "last_exported_cursor": "cursor", + "created_at": "2026-11-01T00:00:00Z", + "updated_at": "2026-11-02T00:00:00Z" +}` + +func destinationForTest(t *testing.T, raw string) kernel.AuditLogExportDestination { + t.Helper() + var destination kernel.AuditLogExportDestination + if err := json.Unmarshal([]byte(raw), &destination); err != nil { + t.Fatalf("unmarshal destination: %v", err) + } + return destination +} + +func model(t *testing.T) destinationModel { + t.Helper() + return destinationModel{ + ID: types.StringValue("dest_123"), + Type: types.StringValue("s3"), + Format: types.StringValue("jsonl.gz"), + Bucket: types.StringValue("audit-bucket"), + Region: types.StringValue("us-east-1"), + Prefix: types.StringValue("kernel/"), + RoleARN: types.StringValue("arn:aws:iam::123456789012:role/kernel-audit"), + KMSKeyID: types.StringNull(), + Status: types.StringValue("active"), + ExternalID: types.StringValue("ext_123"), + KernelRoleARN: types.StringValue("arn:aws:iam::999999999999:role/kernel-exporter"), + } +} + +func marshal(t *testing.T, v json.Marshaler) string { + t.Helper() + body, err := v.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(body) +} + +func TestExpandCreate(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + kmsKeyID types.String + want string + }{ + "without KMS key": { + kmsKeyID: types.StringNull(), + want: `{"bucket":"audit-bucket","format":"jsonl.gz","prefix":"kernel/","region":"us-east-1","role_arn":"arn:aws:iam::123456789012:role/kernel-audit","type":"s3"}`, + }, + "with KMS key": { + kmsKeyID: types.StringValue("alias/audit"), + want: `{"bucket":"audit-bucket","format":"jsonl.gz","prefix":"kernel/","region":"us-east-1","role_arn":"arn:aws:iam::123456789012:role/kernel-audit","type":"s3","kms_key_id":"alias/audit"}`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + plan := model(t) + plan.KMSKeyID = test.kmsKeyID + if got := marshal(t, expandCreate(plan)); got != test.want { + t.Fatalf("create body = %s, want %s", got, test.want) + } + }) + } +} + +func TestExpandUpdateSendsOnlyChangedFields(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + mutate func(plan, state *destinationModel) + want string + }{ + "unchanged": { + mutate: func(*destinationModel, *destinationModel) {}, + want: `{}`, + }, + "bucket, region, prefix and role": { + mutate: func(plan, _ *destinationModel) { + plan.Bucket = types.StringValue("other-bucket") + plan.Region = types.StringValue("us-gov-west-1") + plan.Prefix = types.StringValue("audit/") + plan.RoleARN = types.StringValue("arn:aws:iam::123456789012:role/other") + }, + want: `{"bucket":"other-bucket","prefix":"audit/","region":"us-gov-west-1","role_arn":"arn:aws:iam::123456789012:role/other"}`, + }, + "set KMS key": { + mutate: func(plan, _ *destinationModel) { + plan.KMSKeyID = types.StringValue("alias/audit") + }, + want: `{"kms_key_id":"alias/audit"}`, + }, + "clear KMS key": { + mutate: func(_, state *destinationModel) { + state.KMSKeyID = types.StringValue("alias/audit") + }, + want: `{"kms_key_id":""}`, + }, + "pause": { + mutate: func(plan, _ *destinationModel) { + plan.Status = types.StringValue("paused") + }, + want: `{"status":"paused"}`, + }, + "unknown status": { + mutate: func(plan, _ *destinationModel) { + plan.Status = types.StringUnknown() + }, + want: `{}`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + plan, state := model(t), model(t) + test.mutate(&plan, &state) + if got := marshal(t, expandUpdate(plan, state)); got != test.want { + t.Fatalf("update body = %s, want %s", got, test.want) + } + }) + } +} + +func TestFlattenKeepsDurableFieldsOnly(t *testing.T) { + t.Parallel() + + got, diags := flatten(destinationForTest(t, remoteDestination)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if want := model(t); got != want { + t.Fatalf("flatten = %+v, want %+v", got, want) + } +} + +func TestFlattenKMSKey(t *testing.T) { + t.Parallel() + + remote := destinationForTest(t, remoteDestination) + remote.KmsKeyID = "alias/audit" + got, diags := flatten(remote) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got.KMSKeyID.ValueString() != "alias/audit" { + t.Fatalf("kms_key_id = %v, want alias/audit", got.KMSKeyID) + } +} + +func TestFlattenRejectsMissingRequiredFields(t *testing.T) { + t.Parallel() + + for _, field := range []string{"id", "type", "format", "bucket", "region", "role_arn", "status", "external_id", "kernel_role_arn"} { + t.Run(field, func(t *testing.T) { + t.Parallel() + + var raw map[string]any + if err := json.Unmarshal([]byte(remoteDestination), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + delete(raw, field) + body, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + _, diags := flatten(destinationForTest(t, string(body))) + if !diags.HasError() { + t.Fatalf("missing %s was accepted", field) + } + }) + } +} diff --git a/internal/resources/auditlogexportdestination/model.go b/internal/resources/auditlogexportdestination/model.go new file mode 100644 index 0000000..c99aa63 --- /dev/null +++ b/internal/resources/auditlogexportdestination/model.go @@ -0,0 +1,17 @@ +package auditlogexportdestination + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type destinationModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` + Format types.String `tfsdk:"format"` + Bucket types.String `tfsdk:"bucket"` + Region types.String `tfsdk:"region"` + Prefix types.String `tfsdk:"prefix"` + RoleARN types.String `tfsdk:"role_arn"` + KMSKeyID types.String `tfsdk:"kms_key_id"` + Status types.String `tfsdk:"status"` + ExternalID types.String `tfsdk:"external_id"` + KernelRoleARN types.String `tfsdk:"kernel_role_arn"` +} diff --git a/internal/resources/auditlogexportdestination/resource.go b/internal/resources/auditlogexportdestination/resource.go new file mode 100644 index 0000000..5e64e0d --- /dev/null +++ b/internal/resources/auditlogexportdestination/resource.go @@ -0,0 +1,262 @@ +package auditlogexportdestination + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +var ( + _ tfresource.Resource = (*destinationResource)(nil) + _ tfresource.ResourceWithConfigure = (*destinationResource)(nil) + _ tfresource.ResourceWithImportState = (*destinationResource)(nil) +) + +type destinationClient interface { + CreateAuditLogExportDestination(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) + GetAuditLogExportDestination(context.Context, string) (*kernel.AuditLogExportDestination, error) + UpdateAuditLogExportDestination(context.Context, string, kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) + DeleteAuditLogExportDestination(context.Context, string) error +} + +type destinationResource struct { + client destinationClient +} + +func newResourceWithClient(client destinationClient) *destinationResource { + return &destinationResource{client: client} +} + +func NewResource() tfresource.Resource { + return &destinationResource{} +} + +func (r *destinationResource) Metadata(ctx context.Context, req tfresource.MetadataRequest, resp *tfresource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_audit_log_export_destination" +} + +func (r *destinationResource) Schema(ctx context.Context, req tfresource.SchemaRequest, resp *tfresource.SchemaResponse) { + resp.Schema = destinationSchema() +} + +func (r *destinationResource) Configure(ctx context.Context, req tfresource.ConfigureRequest, resp *tfresource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(destinationClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the audit log export destination durable client contract.", + ) + return + } + + r.client = client +} + +func (r *destinationResource) Create(ctx context.Context, req tfresource.CreateRequest, resp *tfresource.CreateResponse) { + var plan destinationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + created, err := r.client.CreateAuditLogExportDestination(ctx, expandCreate(plan)) + if err != nil { + if definiteFailure(err) { + resp.Diagnostics.AddError("Create Kernel Audit Log Export Destination", errorDetail(err)) + return + } + addUncertainCreateDiagnostic(&resp.Diagnostics, plan.Bucket.ValueString(), err.Error()) + return + } + if created == nil { + addUncertainCreateDiagnostic(&resp.Diagnostics, plan.Bucket.ValueString(), "Kernel returned an empty audit log export destination response.") + return + } + + state, diags := flatten(*created) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // The create request has no status, so a destination planned as paused + // is paused right after it is created. + if !plan.Status.IsUnknown() && !plan.Status.Equal(state.Status) { + params := kernel.AuditLogExportDestinationUpdateParams{ + UpdateAuditLogExportDestinationRequest: kernel.UpdateAuditLogExportDestinationRequestParam{ + Status: kernel.UpdateAuditLogExportDestinationRequestStatus(plan.Status.ValueString()), + }, + } + updated, err := r.client.UpdateAuditLogExportDestination(ctx, state.ID.ValueString(), params) + if err != nil || updated == nil { + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) + detail := "Kernel returned an empty response." + if err != nil { + detail = errorDetail(err) + } + resp.Diagnostics.AddError( + "Set Kernel Audit Log Export Destination Status", + "Kernel created audit log export destination "+strconv.Quote(state.ID.ValueString())+" but did not set its status to "+ + strconv.Quote(plan.Status.ValueString())+". Run `terraform untaint` on the resource and apply again to set the status without replacing it. "+detail, + ) + return + } + state, diags = flatten(*updated) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *destinationResource) Read(ctx context.Context, req tfresource.ReadRequest, resp *tfresource.ReadResponse) { + var state destinationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + id := state.ID.ValueString() + remote, err := r.client.GetAuditLogExportDestination(ctx, id) + if err != nil { + if projectscope.IsNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Read Kernel Audit Log Export Destination", errorDetail(err)) + return + } + if remote == nil { + resp.Diagnostics.AddError( + "Read Kernel Audit Log Export Destination", + "Kernel returned an empty response while reading audit log export destination "+strconv.Quote(id)+".", + ) + return + } + + next, diags := flatten(*remote) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if next.ID.ValueString() != id { + resp.Diagnostics.AddError( + "Invalid Kernel Audit Log Export Destination Response", + "Kernel returned audit log export destination ID "+strconv.Quote(next.ID.ValueString())+" while reading Terraform state ID "+strconv.Quote(id)+".", + ) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, next)...) +} + +func (r *destinationResource) Update(ctx context.Context, req tfresource.UpdateRequest, resp *tfresource.UpdateResponse) { + var plan, state destinationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + id := state.ID.ValueString() + remote, err := r.client.UpdateAuditLogExportDestination(ctx, id, expandUpdate(plan, state)) + if err != nil { + resp.Diagnostics.AddError("Update Kernel Audit Log Export Destination", errorDetail(err)) + return + } + if remote == nil { + resp.Diagnostics.AddError( + "Update Kernel Audit Log Export Destination", + "Kernel returned an empty response while updating audit log export destination "+strconv.Quote(id)+".", + ) + return + } + + next, diags := flatten(*remote) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if next.ID.ValueString() != id { + resp.Diagnostics.AddError( + "Invalid Kernel Audit Log Export Destination Response", + "Kernel returned audit log export destination ID "+strconv.Quote(next.ID.ValueString())+" while updating "+strconv.Quote(id)+".", + ) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, next)...) +} + +func (r *destinationResource) Delete(ctx context.Context, req tfresource.DeleteRequest, resp *tfresource.DeleteResponse) { + var state destinationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !r.configured(&resp.Diagnostics) { + return + } + + err := r.client.DeleteAuditLogExportDestination(ctx, state.ID.ValueString()) + if err != nil && !projectscope.IsNotFound(err) { + resp.Diagnostics.AddError("Delete Kernel Audit Log Export Destination", errorDetail(err)) + } +} + +func (r *destinationResource) ImportState(ctx context.Context, req tfresource.ImportStateRequest, resp *tfresource.ImportStateResponse) { + tfresource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *destinationResource) configured(diags *diag.Diagnostics) bool { + if r.client == nil { + diags.AddError( + "Missing Kernel Client", + "The Kernel provider was not configured before using the audit log export destination resource.", + ) + return false + } + return true +} + +func definiteFailure(err error) bool { + var apiError *kernel.Error + return errors.As(err, &apiError) && apiError.StatusCode >= http.StatusBadRequest && apiError.StatusCode < http.StatusInternalServerError +} + +func errorDetail(err error) string { + var apiError *kernel.Error + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusForbidden { + return "Audit log export destinations are organization-scoped and need an API key that is not bound to a project, on a Kernel plan that includes audit logs. Underlying error: " + err.Error() + } + return err.Error() +} + +func addUncertainCreateDiagnostic(diags *diag.Diagnostics, bucket, reason string) { + diags.AddError( + "Kernel Audit Log Export Destination Creation Outcome Uncertain", + "Kernel may have created an audit log export destination for bucket "+strconv.Quote(bucket)+", but Terraform did not receive a confirmation. "+ + "Check Kernel for the destination. If it exists, import its ID before applying again; otherwise retry the apply. "+ + "Reason: "+reason, + ) +} diff --git a/internal/resources/auditlogexportdestination/resource_acc_test.go b/internal/resources/auditlogexportdestination/resource_acc_test.go new file mode 100644 index 0000000..5302d1d --- /dev/null +++ b/internal/resources/auditlogexportdestination/resource_acc_test.go @@ -0,0 +1,158 @@ +package auditlogexportdestination_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/kernel/terraform-provider-kernel/internal/acctest" +) + +const destinationResourceName = "kernel_audit_log_export_destination.test" + +func TestAccAuditLogExportDestinationLifecycle(t *testing.T) { + bucket := os.Getenv(acctest.EnvAuditExportBucket) + region := os.Getenv(acctest.EnvAuditExportRegion) + roleARN := os.Getenv(acctest.EnvAuditExportRoleARN) + prefix := acctest.UniqueName(t, "audit") + "/" + updatedPrefix := acctest.UniqueName(t, "audit-updated") + "/" + finalConfig := testAccDestinationConfig(bucket, region, updatedPrefix, roleARN, "paused") + var destinationID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + if bucket == "" || region == "" || roleARN == "" { + t.Skipf("set %s, %s and %s to run the audit log export destination acceptance test", + acctest.EnvAuditExportBucket, acctest.EnvAuditExportRegion, acctest.EnvAuditExportRoleARN) + } + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + CheckDestroy: testAccCheckDestinationDestroyed(), + Steps: []resource.TestStep{ + { + Config: testAccDestinationConfig(bucket, region, prefix, roleARN, ""), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureDestinationID(t, &destinationID), + resource.TestCheckResourceAttr(destinationResourceName, "type", "s3"), + resource.TestCheckResourceAttr(destinationResourceName, "format", "jsonl.gz"), + resource.TestCheckResourceAttr(destinationResourceName, "bucket", bucket), + resource.TestCheckResourceAttr(destinationResourceName, "prefix", prefix), + resource.TestCheckResourceAttr(destinationResourceName, "status", "active"), + resource.TestCheckResourceAttrSet(destinationResourceName, "external_id"), + resource.TestCheckResourceAttrSet(destinationResourceName, "kernel_role_arn"), + ), + }, + { + Config: finalConfig, + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureDestinationID(t, &destinationID), + resource.TestCheckResourceAttr(destinationResourceName, "prefix", updatedPrefix), + resource.TestCheckResourceAttr(destinationResourceName, "status", "paused"), + testAccCheckRemoteStatus("paused"), + ), + }, + { + Config: finalConfig, + PlanOnly: true, + }, + { + ResourceName: destinationResourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccDestinationConfig(bucket, region, prefix, roleARN, status string) string { + config := acctest.ProviderConfig() + fmt.Sprintf(` +resource "kernel_audit_log_export_destination" "test" { + bucket = %q + region = %q + prefix = %q + role_arn = %q +`, bucket, region, prefix, roleARN) + if status != "" { + config += fmt.Sprintf(" status = %q\n", status) + } + return config + "}\n" +} + +func testAccCaptureDestinationID(t *testing.T, destinationID *string) resource.TestCheckFunc { + t.Helper() + + return func(state *terraform.State) error { + id, err := destinationStateID(state) + if err != nil { + return err + } + if *destinationID != "" && *destinationID != id { + return fmt.Errorf("Kernel audit log export destination ID changed from %s to %s", *destinationID, id) + } + if *destinationID == "" { + *destinationID = id + acctest.CleanupAuditLogExportDestination(t, id) + } + return nil + } +} + +func testAccCheckRemoteStatus(want string) resource.TestCheckFunc { + return func(state *terraform.State) error { + id, err := destinationStateID(state) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + remote, err := acctest.ClientFromEnv().GetAuditLogExportDestination(ctx, id) + if err != nil { + return fmt.Errorf("read Kernel audit log export destination %s: %w", id, err) + } + if got := string(remote.Status); got != want { + return fmt.Errorf("Kernel audit log export destination status = %s, want %s", got, want) + } + return nil + } +} + +func testAccCheckDestinationDestroyed() resource.TestCheckFunc { + return func(state *terraform.State) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := acctest.ClientFromEnv() + for _, resourceState := range state.RootModule().Resources { + if resourceState.Type != "kernel_audit_log_export_destination" || resourceState.Primary == nil || resourceState.Primary.ID == "" { + continue + } + + _, err := client.GetAuditLogExportDestination(ctx, resourceState.Primary.ID) + if acctest.IsNotFound(err) { + continue + } + if err != nil { + return fmt.Errorf("read Kernel audit log export destination %s after destroy: %w", resourceState.Primary.ID, err) + } + return fmt.Errorf("Kernel audit log export destination %s still exists after destroy", resourceState.Primary.ID) + } + return nil + } +} + +func destinationStateID(state *terraform.State) (string, error) { + resourceState, ok := state.RootModule().Resources[destinationResourceName] + if !ok { + return "", fmt.Errorf("missing resource %s in Terraform state", destinationResourceName) + } + if resourceState.Primary == nil || resourceState.Primary.ID == "" { + return "", fmt.Errorf("missing ID for %s in Terraform state", destinationResourceName) + } + return resourceState.Primary.ID, nil +} diff --git a/internal/resources/auditlogexportdestination/resource_test.go b/internal/resources/auditlogexportdestination/resource_test.go new file mode 100644 index 0000000..7887ce1 --- /dev/null +++ b/internal/resources/auditlogexportdestination/resource_test.go @@ -0,0 +1,485 @@ +package auditlogexportdestination + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "testing" + + tfresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" +) + +type fakeDestinationClient struct { + create func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) + get func(context.Context, string) (*kernel.AuditLogExportDestination, error) + update func(context.Context, string, kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) + delete func(context.Context, string) error +} + +func (f fakeDestinationClient) CreateAuditLogExportDestination(ctx context.Context, params kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + if f.create == nil { + return nil, errors.New("unexpected create") + } + return f.create(ctx, params) +} + +func (f fakeDestinationClient) GetAuditLogExportDestination(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { + if f.get == nil { + return nil, errors.New("unexpected get") + } + return f.get(ctx, id) +} + +func (f fakeDestinationClient) UpdateAuditLogExportDestination(ctx context.Context, id string, params kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + if f.update == nil { + return nil, errors.New("unexpected update") + } + return f.update(ctx, id, params) +} + +func (f fakeDestinationClient) DeleteAuditLogExportDestination(ctx context.Context, id string) error { + if f.delete == nil { + return errors.New("unexpected delete") + } + return f.delete(ctx, id) +} + +func destinationResponse(t *testing.T, mutate func(*kernel.AuditLogExportDestination)) (*kernel.AuditLogExportDestination, error) { + t.Helper() + destination := destinationForTest(t, remoteDestination) + if mutate != nil { + mutate(&destination) + } + return &destination, nil +} + +func apiError(t *testing.T, status int, raw string) *kernel.Error { + t.Helper() + + var apiError kernel.Error + if err := json.Unmarshal([]byte(raw), &apiError); err != nil { + t.Fatalf("unmarshal API error: %v", err) + } + apiError.StatusCode = status + apiError.Request = &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Scheme: "https", Host: "api.example", Path: "/audit-logs/export/destinations"}, + } + apiError.Response = &http.Response{StatusCode: status, Status: http.StatusText(status)} + return &apiError +} + +func newState(t *testing.T, value *destinationModel) tfsdk.State { + t.Helper() + + state := tfsdk.State{Schema: destinationSchema()} + if value == nil { + state.RemoveResource(context.Background()) + return state + } + if diags := state.Set(context.Background(), value); diags.HasError() { + t.Fatalf("set state: %v", diags) + } + return state +} + +func newPlan(t *testing.T, value destinationModel) tfsdk.Plan { + t.Helper() + + plan := tfsdk.Plan{Schema: destinationSchema()} + if diags := plan.Set(context.Background(), value); diags.HasError() { + t.Fatalf("set plan: %v", diags) + } + return plan +} + +func stateModel(t *testing.T, state tfsdk.State) destinationModel { + t.Helper() + + var m destinationModel + if diags := state.Get(context.Background(), &m); diags.HasError() { + t.Fatalf("get state: %v", diags) + } + return m +} + +func plannedModel(t *testing.T) destinationModel { + t.Helper() + + m := model(t) + m.ID = types.StringUnknown() + m.Status = types.StringUnknown() + m.ExternalID = types.StringUnknown() + m.KernelRoleARN = types.StringUnknown() + return m +} + +func TestCreatePersistsState(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return destinationResponse(t, nil) + }, + }) + resp := &tfresource.CreateResponse{State: newState(t, nil)} + + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plannedModel(t))}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if got, want := stateModel(t, resp.State), model(t); got != want { + t.Fatalf("state = %+v, want %+v", got, want) + } +} + +func TestCreatePausedDestinationPausesAfterCreate(t *testing.T) { + t.Parallel() + + var gotID, gotBody string + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return destinationResponse(t, nil) + }, + update: func(ctx context.Context, id string, params kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + gotID = id + gotBody = marshal(t, params) + return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { + d.Status = kernel.AuditLogExportDestinationStatusPaused + }) + }, + }) + plan := plannedModel(t) + plan.Status = types.StringValue("paused") + resp := &tfresource.CreateResponse{State: newState(t, nil)} + + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plan)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotID != "dest_123" || gotBody != `{"status":"paused"}` { + t.Fatalf("update %q with %s, want dest_123 with status paused", gotID, gotBody) + } + if got := stateModel(t, resp.State).Status.ValueString(); got != "paused" { + t.Fatalf("status = %q, want paused", got) + } +} + +func TestCreateKeepsStateWhenPauseFails(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return destinationResponse(t, nil) + }, + update: func(context.Context, string, kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + return nil, errors.New("connection reset") + }, + }) + plan := plannedModel(t) + plan.Status = types.StringValue("paused") + resp := &tfresource.CreateResponse{State: newState(t, nil)} + + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plan)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected status error") + } + if got := stateModel(t, resp.State).ID.ValueString(); got != "dest_123" { + t.Fatalf("state id = %q, want created dest_123 kept", got) + } +} + +func TestCreateFailures(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + create func() (*kernel.AuditLogExportDestination, error) + wantSummary string + wantDetail string + }{ + "definite API rejection": { + create: func() (*kernel.AuditLogExportDestination, error) { + return nil, apiError(t, http.StatusBadRequest, `{"code":"bad_request"}`) + }, + wantSummary: "Create Kernel Audit Log Export Destination", + }, + "project-scoped credential or plan": { + create: func() (*kernel.AuditLogExportDestination, error) { + return nil, apiError(t, http.StatusForbidden, `{"code":"forbidden"}`) + }, + wantSummary: "Create Kernel Audit Log Export Destination", + wantDetail: "plan that includes audit logs", + }, + "server error": { + create: func() (*kernel.AuditLogExportDestination, error) { + return nil, apiError(t, http.StatusInternalServerError, `{"code":"internal_error"}`) + }, + wantSummary: "Kernel Audit Log Export Destination Creation Outcome Uncertain", + }, + "transport error": { + create: func() (*kernel.AuditLogExportDestination, error) { + return nil, errors.New("connection reset") + }, + wantSummary: "Kernel Audit Log Export Destination Creation Outcome Uncertain", + wantDetail: "connection reset", + }, + "empty response": { + create: func() (*kernel.AuditLogExportDestination, error) { + return nil, nil + }, + wantSummary: "Kernel Audit Log Export Destination Creation Outcome Uncertain", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return test.create() + }, + }) + resp := &tfresource.CreateResponse{State: newState(t, nil)} + + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plannedModel(t))}, resp) + + if got := len(resp.Diagnostics); got != 1 { + t.Fatalf("diagnostics = %v, want one error", resp.Diagnostics) + } + if got := resp.Diagnostics[0].Summary(); got != test.wantSummary { + t.Fatalf("summary = %q, want %q", got, test.wantSummary) + } + if got := resp.Diagnostics[0].Detail(); !strings.Contains(got, test.wantDetail) { + t.Fatalf("detail = %q, want it to contain %q", got, test.wantDetail) + } + if !resp.State.Raw.IsNull() { + t.Fatal("failed create persisted state") + } + }) + } +} + +func TestReadRefreshesState(t *testing.T) { + t.Parallel() + + var gotID string + r := newResourceWithClient(fakeDestinationClient{ + get: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { + gotID = id + return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { + d.Status = kernel.AuditLogExportDestinationStatusPaused + }) + }, + }) + prior := model(t) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotID != "dest_123" { + t.Fatalf("get id = %q, want dest_123", gotID) + } + if got := stateModel(t, resp.State).Status.ValueString(); got != "paused" { + t.Fatalf("status = %q, want paused", got) + } +} + +func TestReadRemovesConfirmedMissingDestination(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + get: func(context.Context, string) (*kernel.AuditLogExportDestination, error) { + return nil, apiError(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }) + prior := model(t) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if !resp.State.Raw.IsNull() { + t.Fatal("missing destination was not removed from state") + } +} + +func TestReadRejectsCanonicalIDMismatch(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + get: func(context.Context, string) (*kernel.AuditLogExportDestination, error) { + return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { d.ID = "dest_456" }) + }, + }) + prior := model(t) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected ID mismatch error") + } + if got := stateModel(t, resp.State).ID.ValueString(); got != "dest_123" { + t.Fatalf("state id = %q, want preserved dest_123", got) + } +} + +func TestUpdateSendsPatch(t *testing.T) { + t.Parallel() + + var gotID, gotBody string + r := newResourceWithClient(fakeDestinationClient{ + update: func(ctx context.Context, id string, params kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + gotID = id + gotBody = marshal(t, params) + return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { d.Prefix = "audit/" }) + }, + }) + prior := model(t) + plan := model(t) + plan.Prefix = types.StringValue("audit/") + resp := &tfresource.UpdateResponse{State: newState(t, &prior)} + + r.Update(context.Background(), tfresource.UpdateRequest{Plan: newPlan(t, plan), State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if gotID != "dest_123" || gotBody != `{"prefix":"audit/"}` { + t.Fatalf("update %q with %s, want dest_123 with prefix", gotID, gotBody) + } + if got := stateModel(t, resp.State); got != plan { + t.Fatalf("state = %+v, want %+v", got, plan) + } +} + +func TestUpdateRejectsIDMismatch(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + update: func(context.Context, string, kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { d.ID = "dest_456" }) + }, + }) + prior := model(t) + plan := model(t) + plan.Bucket = types.StringValue("other-bucket") + resp := &tfresource.UpdateResponse{State: newState(t, &prior)} + + r.Update(context.Background(), tfresource.UpdateRequest{Plan: newPlan(t, plan), State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected ID mismatch error") + } +} + +func TestDeleteToleratesMissingDestination(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + delete: func(context.Context, string) error { + return apiError(t, http.StatusNotFound, `{"code":"not_found"}`) + }, + }) + prior := model(t) + resp := &tfresource.DeleteResponse{State: newState(t, &prior)} + + r.Delete(context.Background(), tfresource.DeleteRequest{State: newState(t, &prior)}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } +} + +func TestDeleteReportsFailure(t *testing.T) { + t.Parallel() + + r := newResourceWithClient(fakeDestinationClient{ + delete: func(context.Context, string) error { + return apiError(t, http.StatusForbidden, `{"code":"forbidden"}`) + }, + }) + prior := model(t) + resp := &tfresource.DeleteResponse{State: newState(t, &prior)} + + r.Delete(context.Background(), tfresource.DeleteRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() { + t.Fatal("expected delete error") + } +} + +func TestImportStateSetsID(t *testing.T) { + t.Parallel() + + resp := &tfresource.ImportStateResponse{State: newState(t, nil)} + + NewResource().(*destinationResource).ImportState(context.Background(), tfresource.ImportStateRequest{ID: "dest_123"}, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + if got := stateModel(t, resp.State).ID.ValueString(); got != "dest_123" { + t.Fatalf("state id = %q, want dest_123", got) + } +} + +func TestConfigure(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + data any + wantError bool + wantClient bool + }{ + "durable client": {data: fakeDestinationClient{}, wantClient: true}, + "unexpected data": {data: "not a client", wantError: true}, + "nil provider data": {}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := &destinationResource{} + var resp tfresource.ConfigureResponse + r.Configure(context.Background(), tfresource.ConfigureRequest{ProviderData: test.data}, &resp) + + if got := resp.Diagnostics.HasError(); got != test.wantError { + t.Fatalf("has error = %t, want %t: %v", got, test.wantError, resp.Diagnostics) + } + if got := r.client != nil; got != test.wantClient { + t.Fatalf("client configured = %t, want %t", got, test.wantClient) + } + }) + } +} + +func TestOperationsRequireConfiguredClient(t *testing.T) { + t.Parallel() + + r := &destinationResource{} + prior := model(t) + resp := &tfresource.ReadResponse{State: newState(t, &prior)} + + r.Read(context.Background(), tfresource.ReadRequest{State: newState(t, &prior)}, resp) + + if !resp.Diagnostics.HasError() || resp.Diagnostics[0].Summary() != "Missing Kernel Client" { + t.Fatalf("diagnostics = %v, want Missing Kernel Client", resp.Diagnostics) + } +} diff --git a/internal/resources/auditlogexportdestination/schema.go b/internal/resources/auditlogexportdestination/schema.go new file mode 100644 index 0000000..99c5b40 --- /dev/null +++ b/internal/resources/auditlogexportdestination/schema.go @@ -0,0 +1,111 @@ +package auditlogexportdestination + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + rschema "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" + kernel "github.com/kernel/kernel-go-sdk" +) + +const ( + typeS3 = string(kernel.AuditLogExportDestinationTypeS3) + formatJSONL = string(kernel.AuditLogExportDestinationFormatJSONLGz) + statusActive = string(kernel.AuditLogExportDestinationStatusActive) + statusPaused = string(kernel.AuditLogExportDestinationStatusPaused) +) + +func destinationSchema() rschema.Schema { + nonEmpty := []validator.String{stringvalidator.LengthAtLeast(1)} + + return rschema.Schema{ + MarkdownDescription: "Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, `kernel_role_arn`, assumes your `role_arn` with `external_id` as the `sts:ExternalId` condition. " + + "Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. " + + "Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. " + + "Replacing a destination issues a new `external_id`, so the role's trust policy has to be updated to match.", + Attributes: map[string]rschema.Attribute{ + "id": rschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique audit-log export destination identifier.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "type": rschema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(typeS3), + MarkdownDescription: "Destination type. Only `s3` is supported. Changing it replaces the destination.", + Validators: []validator.String{ + stringvalidator.OneOf(typeS3), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "format": rschema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(formatJSONL), + MarkdownDescription: "Object format. Only `jsonl.gz` is supported. Changing it replaces the destination.", + Validators: []validator.String{ + stringvalidator.OneOf(formatJSONL), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "bucket": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "S3 bucket name.", + Validators: nonEmpty, + }, + "region": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "AWS region of the bucket.", + Validators: nonEmpty, + }, + "prefix": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "Key prefix for exported objects.", + }, + "role_arn": rschema.StringAttribute{ + Required: true, + MarkdownDescription: "ARN of the IAM role in your account that Kernel assumes to write objects.", + Validators: nonEmpty, + }, + "kms_key_id": rschema.StringAttribute{ + Optional: true, + MarkdownDescription: "KMS key ID, alias, or ARN used to encrypt exported objects. Removing it clears the key.", + Validators: nonEmpty, + }, + "status": rschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "`active` or `paused`. Pausing stops delivery, and resuming starts again from the time of the resume. When omitted, Terraform does not manage the status.", + Validators: []validator.String{ + stringvalidator.OneOf(statusActive, statusPaused), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "external_id": rschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Value Kernel sends as `sts:ExternalId` when it assumes `role_arn`. Require it in the role's trust policy.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "kernel_role_arn": rschema.StringAttribute{ + Computed: true, + MarkdownDescription: "Kernel role that assumes `role_arn`. Allow it as the principal in the role's trust policy.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} diff --git a/internal/resources/auditlogexportdestination/schema_test.go b/internal/resources/auditlogexportdestination/schema_test.go new file mode 100644 index 0000000..6e4cf62 --- /dev/null +++ b/internal/resources/auditlogexportdestination/schema_test.go @@ -0,0 +1,79 @@ +package auditlogexportdestination + +import ( + "context" + "testing" + + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestSchemaContainsOnlyDurableAttributes(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + required, optional, computed bool + }{ + "id": {computed: true}, + "type": {optional: true, computed: true}, + "format": {optional: true, computed: true}, + "bucket": {required: true}, + "region": {required: true}, + "prefix": {required: true}, + "role_arn": {required: true}, + "kms_key_id": {optional: true}, + "status": {optional: true, computed: true}, + "external_id": {computed: true}, + "kernel_role_arn": {computed: true}, + } + + attributes := destinationSchema().Attributes + if len(attributes) != len(tests) { + t.Fatalf("attribute count = %d, want %d", len(attributes), len(tests)) + } + for name, want := range tests { + attribute, ok := attributes[name].(rschema.StringAttribute) + if !ok { + t.Fatalf("%s is not a string attribute: %#v", name, attributes[name]) + } + if attribute.Required != want.required || attribute.Optional != want.optional || attribute.Computed != want.computed { + t.Fatalf("%s flags = required %t, optional %t, computed %t; want %+v", name, attribute.Required, attribute.Optional, attribute.Computed, want) + } + } +} + +func TestSchemaValidatesEnums(t *testing.T) { + t.Parallel() + + attributes := destinationSchema().Attributes + for _, test := range []struct { + attribute, value string + wantError bool + }{ + {attribute: "type", value: "s3"}, + {attribute: "type", value: "gcs", wantError: true}, + {attribute: "format", value: "jsonl.gz"}, + {attribute: "format", value: "csv", wantError: true}, + {attribute: "status", value: "active"}, + {attribute: "status", value: "paused"}, + {attribute: "status", value: "disabled", wantError: true}, + {attribute: "kms_key_id", value: "", wantError: true}, + {attribute: "bucket", value: "", wantError: true}, + } { + t.Run(test.attribute+"="+test.value, func(t *testing.T) { + t.Parallel() + + attribute := attributes[test.attribute].(rschema.StringAttribute) + hasError := false + for _, v := range attribute.Validators { + resp := &validator.StringResponse{} + v.ValidateString(context.Background(), validator.StringRequest{ConfigValue: types.StringValue(test.value)}, resp) + hasError = hasError || resp.Diagnostics.HasError() + } + if hasError != test.wantError { + t.Fatalf("validation error = %t, want %t", hasError, test.wantError) + } + }) + } +} From 24fd75d8617ac614b878cf41b5e2bbfaf37c2532 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:45:26 +0000 Subject: [PATCH 2/2] Fix audit export prefix and activation handling --- docs/acceptance.md | 2 +- .../resources/audit_log_export_destination.md | 6 +- examples/audit-log-export-destination/main.tf | 3 + .../auditlogexportdestination/convert.go | 8 +- .../auditlogexportdestination/convert_test.go | 50 ++++++-- .../auditlogexportdestination/resource.go | 18 +-- .../resource_acc_test.go | 13 ++- .../resource_terraform_test.go | 107 ++++++++++++++++++ .../resource_test.go | 74 +++++++----- .../auditlogexportdestination/schema.go | 3 +- 10 files changed, 231 insertions(+), 53 deletions(-) create mode 100644 internal/resources/auditlogexportdestination/resource_terraform_test.go diff --git a/docs/acceptance.md b/docs/acceptance.md index 91f1975..7173531 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -52,7 +52,7 @@ instead. | `kernel_project` resource | `./internal/resources/project` | Create, rename with stable ID, no-drift plan, canonical-ID import, post-import no drift, delete, and HTTP 404 verification. | | `kernel_browser_pool` resource | `./internal/resources/browserpool` | Create, durable update with stable ID, opt into rebuilding and acquire an idle browser after a configuration change, no-drift plan, provider-default and explicit project scope, bare and project-qualified import, non-force delete, and HTTP 404 verification. | | `kernel_telemetry_destination` resource | `./internal/resources/telemetrydestination` | Create with headers, update name, endpoint, description, and headers with stable ID, remove all headers, verify header names through the API, no-drift plan, canonical-ID import, delete, and HTTP 404 verification. | -| `kernel_audit_log_export_destination` resource | `./internal/resources/auditlogexportdestination` | Create with a unique prefix, verify computed `external_id` and `kernel_role_arn`, update the prefix and pause with stable ID, verify the paused status through the API, no-drift plan, canonical-ID import, delete, and HTTP 404 verification. | +| `kernel_audit_log_export_destination` resource | `./internal/resources/auditlogexportdestination` | Create paused with a unique prefix, verify computed `external_id` and `kernel_role_arn`, activate explicitly, update the prefix and pause with stable ID, verify the paused status through the API, no-drift plan, canonical-ID import, delete, and HTTP 404 verification. | | `kernel_project` data source | `./internal/datasources/project` | Create a unique project fixture, read it by ID and exact name, read the provider-default project, verify durable metadata and no drift, then delete and require coded `not_found`. | | `kernel_profile` data source | `./internal/datasources/profile` | Create a durable profile fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable metadata and no drift, then delete and require coded `not_found`. | | `kernel_proxy` data source | `./internal/datasources/proxy` | Create a managed datacenter proxy fixture through the SDK, read it by ID and exact name with explicit and default project scope, verify durable masked metadata and no drift, then delete and require coded `not_found`. | diff --git a/docs/resources/audit_log_export_destination.md b/docs/resources/audit_log_export_destination.md index 27f962a..2edda26 100644 --- a/docs/resources/audit_log_export_destination.md +++ b/docs/resources/audit_log_export_destination.md @@ -3,12 +3,12 @@ page_title: "kernel_audit_log_export_destination Resource - Kernel" subcategory: "" description: |- - Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, kernel_role_arn, assumes your role_arn with external_id as the sts:ExternalId condition. Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. Replacing a destination issues a new external_id, so the role's trust policy has to be updated to match. + Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, kernel_role_arn, assumes your role_arn with external_id as the sts:ExternalId condition. Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. New destinations are paused. Create the destination, configure the role's trust policy using the computed values, verify destination access, then set status = "active" and apply again. Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. Replacing a destination issues a new external_id, so the role's trust policy has to be updated to match. --- # kernel_audit_log_export_destination (Resource) -Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, `kernel_role_arn`, assumes your `role_arn` with `external_id` as the `sts:ExternalId` condition. Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. Replacing a destination issues a new `external_id`, so the role's trust policy has to be updated to match. +Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, `kernel_role_arn`, assumes your `role_arn` with `external_id` as the `sts:ExternalId` condition. Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. New destinations are paused. Create the destination, configure the role's trust policy using the computed values, verify destination access, then set `status = "active"` and apply again. Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. Replacing a destination issues a new `external_id`, so the role's trust policy has to be updated to match. @@ -26,7 +26,7 @@ Kernel audit-log export destination. Kernel continuously delivers the organizati - `format` (String) Object format. Only `jsonl.gz` is supported. Changing it replaces the destination. - `kms_key_id` (String) KMS key ID, alias, or ARN used to encrypt exported objects. Removing it clears the key. -- `status` (String) `active` or `paused`. Pausing stops delivery, and resuming starts again from the time of the resume. When omitted, Terraform does not manage the status. +- `status` (String) `active` or `paused`. Pausing stops delivery, and resuming starts again from the time of the resume. When omitted, Terraform does not manage the status and new destinations remain paused. - `type` (String) Destination type. Only `s3` is supported. Changing it replaces the destination. ### Read-Only diff --git a/examples/audit-log-export-destination/main.tf b/examples/audit-log-export-destination/main.tf index 2239f35..08265e5 100644 --- a/examples/audit-log-export-destination/main.tf +++ b/examples/audit-log-export-destination/main.tf @@ -28,6 +28,9 @@ resource "kernel_audit_log_export_destination" "example" { region = var.region prefix = "kernel/audit-logs/" role_arn = var.role_arn + # First apply paused, attach the trust policy below, and verify destination + # access. Then change this to "active" and apply again to start delivery. + status = "paused" } # Attach this trust policy to var.role_arn so Kernel can assume it. diff --git a/internal/resources/auditlogexportdestination/convert.go b/internal/resources/auditlogexportdestination/convert.go index 0582cbd..d934143 100644 --- a/internal/resources/auditlogexportdestination/convert.go +++ b/internal/resources/auditlogexportdestination/convert.go @@ -1,6 +1,8 @@ package auditlogexportdestination import ( + "strings" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" kernel "github.com/kernel/kernel-go-sdk" @@ -48,7 +50,7 @@ func expandUpdate(plan, state destinationModel) kernel.AuditLogExportDestination // flatten builds state from a destination response, leaving out delivery // health fields such as last_error and consecutive_failures. -func flatten(remote kernel.AuditLogExportDestination) (destinationModel, diag.Diagnostics) { +func flatten(remote kernel.AuditLogExportDestination, prefix types.String) (destinationModel, diag.Diagnostics) { var diags diag.Diagnostics required := []struct { field, value string @@ -88,6 +90,10 @@ func flatten(remote kernel.AuditLogExportDestination) (destinationModel, diag.Di ExternalID: types.StringValue(remote.ExternalID), KernelRoleARN: types.StringValue(remote.KernelRoleArn), } + // Kernel strips trailing slashes; retain the configured spelling when equivalent. + if !prefix.IsNull() && !prefix.IsUnknown() && strings.TrimRight(prefix.ValueString(), "/") == remote.Prefix { + model.Prefix = prefix + } if remote.KmsKeyID != "" { model.KMSKeyID = types.StringValue(remote.KmsKeyID) } diff --git a/internal/resources/auditlogexportdestination/convert_test.go b/internal/resources/auditlogexportdestination/convert_test.go index 909a194..6886706 100644 --- a/internal/resources/auditlogexportdestination/convert_test.go +++ b/internal/resources/auditlogexportdestination/convert_test.go @@ -17,9 +17,9 @@ const remoteDestination = `{ "format": "jsonl.gz", "bucket": "audit-bucket", "region": "us-east-1", - "prefix": "kernel/", + "prefix": "kernel", "role_arn": "arn:aws:iam::123456789012:role/kernel-audit", - "status": "active", + "status": "paused", "external_id": "ext_123", "kernel_role_arn": "arn:aws:iam::999999999999:role/kernel-exporter", "consecutive_failures": 3, @@ -49,7 +49,7 @@ func model(t *testing.T) destinationModel { Prefix: types.StringValue("kernel/"), RoleARN: types.StringValue("arn:aws:iam::123456789012:role/kernel-audit"), KMSKeyID: types.StringNull(), - Status: types.StringValue("active"), + Status: types.StringValue("paused"), ExternalID: types.StringValue("ext_123"), KernelRoleARN: types.StringValue("arn:aws:iam::999999999999:role/kernel-exporter"), } @@ -126,11 +126,11 @@ func TestExpandUpdateSendsOnlyChangedFields(t *testing.T) { }, want: `{"kms_key_id":""}`, }, - "pause": { + "activate": { mutate: func(plan, _ *destinationModel) { - plan.Status = types.StringValue("paused") + plan.Status = types.StringValue("active") }, - want: `{"status":"paused"}`, + want: `{"status":"active"}`, }, "unknown status": { mutate: func(plan, _ *destinationModel) { @@ -156,7 +156,7 @@ func TestExpandUpdateSendsOnlyChangedFields(t *testing.T) { func TestFlattenKeepsDurableFieldsOnly(t *testing.T) { t.Parallel() - got, diags := flatten(destinationForTest(t, remoteDestination)) + got, diags := flatten(destinationForTest(t, remoteDestination), model(t).Prefix) if diags.HasError() { t.Fatalf("unexpected diagnostics: %v", diags) } @@ -165,12 +165,44 @@ func TestFlattenKeepsDurableFieldsOnly(t *testing.T) { } } +func TestFlattenPrefix(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + prefix types.String + remote string + want string + }{ + {"trailing slash", types.StringValue("kernel/"), "kernel", "kernel/"}, + {"multiple trailing slashes", types.StringValue("kernel///"), "kernel", "kernel///"}, + {"canonical", types.StringValue("kernel"), "kernel", "kernel"}, + {"empty", types.StringValue(""), "", ""}, + {"drift", types.StringValue("kernel/"), "other", "other"}, + {"import", types.StringNull(), "kernel", "kernel"}, + {"unknown", types.StringUnknown(), "kernel", "kernel"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + remote := destinationForTest(t, remoteDestination) + remote.Prefix = test.remote + got, diags := flatten(remote, test.prefix) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if got.Prefix.ValueString() != test.want { + t.Fatalf("prefix = %v, want %q", got.Prefix, test.want) + } + }) + } +} + func TestFlattenKMSKey(t *testing.T) { t.Parallel() remote := destinationForTest(t, remoteDestination) remote.KmsKeyID = "alias/audit" - got, diags := flatten(remote) + got, diags := flatten(remote, model(t).Prefix) if diags.HasError() { t.Fatalf("unexpected diagnostics: %v", diags) } @@ -196,7 +228,7 @@ func TestFlattenRejectsMissingRequiredFields(t *testing.T) { t.Fatalf("marshal: %v", err) } - _, diags := flatten(destinationForTest(t, string(body))) + _, diags := flatten(destinationForTest(t, string(body)), model(t).Prefix) if !diags.HasError() { t.Fatalf("missing %s was accepted", field) } diff --git a/internal/resources/auditlogexportdestination/resource.go b/internal/resources/auditlogexportdestination/resource.go index 5e64e0d..41e00c8 100644 --- a/internal/resources/auditlogexportdestination/resource.go +++ b/internal/resources/auditlogexportdestination/resource.go @@ -87,14 +87,19 @@ func (r *destinationResource) Create(ctx context.Context, req tfresource.CreateR return } - state, diags := flatten(*created) + state, diags := flatten(*created, plan.Prefix) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - // The create request has no status, so a destination planned as paused - // is paused right after it is created. + // Keep the created destination in state even if activation fails. + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) + if resp.Diagnostics.HasError() { + return + } + + // Kernel creates destinations paused; activate only when requested. if !plan.Status.IsUnknown() && !plan.Status.Equal(state.Status) { params := kernel.AuditLogExportDestinationUpdateParams{ UpdateAuditLogExportDestinationRequest: kernel.UpdateAuditLogExportDestinationRequestParam{ @@ -103,7 +108,6 @@ func (r *destinationResource) Create(ctx context.Context, req tfresource.CreateR } updated, err := r.client.UpdateAuditLogExportDestination(ctx, state.ID.ValueString(), params) if err != nil || updated == nil { - resp.Diagnostics.Append(resp.State.Set(ctx, state)...) detail := "Kernel returned an empty response." if err != nil { detail = errorDetail(err) @@ -115,7 +119,7 @@ func (r *destinationResource) Create(ctx context.Context, req tfresource.CreateR ) return } - state, diags = flatten(*updated) + state, diags = flatten(*updated, plan.Prefix) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -153,7 +157,7 @@ func (r *destinationResource) Read(ctx context.Context, req tfresource.ReadReque return } - next, diags := flatten(*remote) + next, diags := flatten(*remote, state.Prefix) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -193,7 +197,7 @@ func (r *destinationResource) Update(ctx context.Context, req tfresource.UpdateR return } - next, diags := flatten(*remote) + next, diags := flatten(*remote, plan.Prefix) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return diff --git a/internal/resources/auditlogexportdestination/resource_acc_test.go b/internal/resources/auditlogexportdestination/resource_acc_test.go index 5302d1d..957fae5 100644 --- a/internal/resources/auditlogexportdestination/resource_acc_test.go +++ b/internal/resources/auditlogexportdestination/resource_acc_test.go @@ -19,7 +19,7 @@ func TestAccAuditLogExportDestinationLifecycle(t *testing.T) { region := os.Getenv(acctest.EnvAuditExportRegion) roleARN := os.Getenv(acctest.EnvAuditExportRoleARN) prefix := acctest.UniqueName(t, "audit") + "/" - updatedPrefix := acctest.UniqueName(t, "audit-updated") + "/" + updatedPrefix := acctest.UniqueName(t, "audit-updated") finalConfig := testAccDestinationConfig(bucket, region, updatedPrefix, roleARN, "paused") var destinationID string @@ -42,11 +42,20 @@ func TestAccAuditLogExportDestinationLifecycle(t *testing.T) { resource.TestCheckResourceAttr(destinationResourceName, "format", "jsonl.gz"), resource.TestCheckResourceAttr(destinationResourceName, "bucket", bucket), resource.TestCheckResourceAttr(destinationResourceName, "prefix", prefix), - resource.TestCheckResourceAttr(destinationResourceName, "status", "active"), + resource.TestCheckResourceAttr(destinationResourceName, "status", "paused"), + testAccCheckRemoteStatus("paused"), resource.TestCheckResourceAttrSet(destinationResourceName, "external_id"), resource.TestCheckResourceAttrSet(destinationResourceName, "kernel_role_arn"), ), }, + { + Config: testAccDestinationConfig(bucket, region, prefix, roleARN, "active"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCaptureDestinationID(t, &destinationID), + resource.TestCheckResourceAttr(destinationResourceName, "status", "active"), + testAccCheckRemoteStatus("active"), + ), + }, { Config: finalConfig, Check: resource.ComposeAggregateTestCheckFunc( diff --git a/internal/resources/auditlogexportdestination/resource_terraform_test.go b/internal/resources/auditlogexportdestination/resource_terraform_test.go new file mode 100644 index 0000000..879548e --- /dev/null +++ b/internal/resources/auditlogexportdestination/resource_terraform_test.go @@ -0,0 +1,107 @@ +package auditlogexportdestination_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/kernel/terraform-provider-kernel/internal/acctest" +) + +func TestDestinationTerraformLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("requires Terraform CLI") + } + + var mu sync.Mutex + var destination map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + if err := json.NewDecoder(r.Body).Decode(&destination); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + destination["id"] = "dest_123" + destination["status"] = "paused" + destination["external_id"] = "ext_123" + destination["kernel_role_arn"] = "arn:aws:iam::999999999999:role/exporter" + case http.MethodPatch: + var patch map[string]any + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + for k, v := range patch { + destination[k] = v + } + case http.MethodDelete: + destination = nil + w.WriteHeader(http.StatusNoContent) + return + } + if destination == nil { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"code":"not_found"}`) + return + } + // Match the API's canonical prefix and paused-on-create behavior. + destination["prefix"] = strings.TrimRight(destination["prefix"].(string), "/") + if err := json.NewEncoder(w).Encode(destination); err != nil { + t.Error(err) + } + })) + defer server.Close() + + config := func(prefix, status string) string { + return fmt.Sprintf(`provider "kernel" { + api_key = "test-key" + base_url = %q +} +resource "kernel_audit_log_export_destination" "test" { + bucket = "audit-bucket" + region = "us-east-1" + prefix = %q + role_arn = "arn:aws:iam::123456789012:role/audit" + %s +} +`, server.URL, prefix, status) + } + initial := config("kernel/audit-logs/", "") + active := config("updated///", `status = "active"`) + canonical := config("updated", `status = "paused"`) + resource.UnitTest(t, resource.TestCase{ + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: initial, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(destinationResourceName, "prefix", "kernel/audit-logs/"), + resource.TestCheckResourceAttr(destinationResourceName, "status", "paused"), + ), + }, + {Config: initial, PlanOnly: true}, + { + Config: active, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(destinationResourceName, "prefix", "updated///"), + resource.TestCheckResourceAttr(destinationResourceName, "status", "active"), + ), + }, + {Config: active, PlanOnly: true}, + {Config: canonical}, + {ResourceName: destinationResourceName, ImportState: true, ImportStateVerify: true}, + {Config: canonical, PlanOnly: true}, + }, + }) +} diff --git a/internal/resources/auditlogexportdestination/resource_test.go b/internal/resources/auditlogexportdestination/resource_test.go index 7887ce1..be3577f 100644 --- a/internal/resources/auditlogexportdestination/resource_test.go +++ b/internal/resources/auditlogexportdestination/resource_test.go @@ -140,7 +140,7 @@ func TestCreatePersistsState(t *testing.T) { } } -func TestCreatePausedDestinationPausesAfterCreate(t *testing.T) { +func TestCreateActiveDestinationActivatesAfterCreate(t *testing.T) { t.Parallel() var gotID, gotBody string @@ -152,12 +152,12 @@ func TestCreatePausedDestinationPausesAfterCreate(t *testing.T) { gotID = id gotBody = marshal(t, params) return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { - d.Status = kernel.AuditLogExportDestinationStatusPaused + d.Status = kernel.AuditLogExportDestinationStatusActive }) }, }) plan := plannedModel(t) - plan.Status = types.StringValue("paused") + plan.Status = types.StringValue("active") resp := &tfresource.CreateResponse{State: newState(t, nil)} r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plan)}, resp) @@ -165,36 +165,49 @@ func TestCreatePausedDestinationPausesAfterCreate(t *testing.T) { if resp.Diagnostics.HasError() { t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) } - if gotID != "dest_123" || gotBody != `{"status":"paused"}` { - t.Fatalf("update %q with %s, want dest_123 with status paused", gotID, gotBody) + if gotID != "dest_123" || gotBody != `{"status":"active"}` { + t.Fatalf("update %q with %s, want dest_123 with status active", gotID, gotBody) } - if got := stateModel(t, resp.State).Status.ValueString(); got != "paused" { - t.Fatalf("status = %q, want paused", got) + if got := stateModel(t, resp.State).Status.ValueString(); got != "active" { + t.Fatalf("status = %q, want active", got) } } -func TestCreateKeepsStateWhenPauseFails(t *testing.T) { +func TestCreateKeepsStateWhenActivationFails(t *testing.T) { t.Parallel() - r := newResourceWithClient(fakeDestinationClient{ - create: func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { - return destinationResponse(t, nil) - }, - update: func(context.Context, string, kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { - return nil, errors.New("connection reset") - }, - }) - plan := plannedModel(t) - plan.Status = types.StringValue("paused") - resp := &tfresource.CreateResponse{State: newState(t, nil)} + tests := map[string]struct { + response *kernel.AuditLogExportDestination + err error + }{ + "transport error": {err: errors.New("connection reset")}, + "empty response": {}, + "malformed response": {response: &kernel.AuditLogExportDestination{ID: "dest_123"}}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + r := newResourceWithClient(fakeDestinationClient{ + create: func(context.Context, kernel.AuditLogExportDestinationNewParams) (*kernel.AuditLogExportDestination, error) { + return destinationResponse(t, nil) + }, + update: func(context.Context, string, kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { + return test.response, test.err + }, + }) + plan := plannedModel(t) + plan.Status = types.StringValue("active") + resp := &tfresource.CreateResponse{State: newState(t, nil)} - r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plan)}, resp) + r.Create(context.Background(), tfresource.CreateRequest{Plan: newPlan(t, plan)}, resp) - if !resp.Diagnostics.HasError() { - t.Fatal("expected status error") - } - if got := stateModel(t, resp.State).ID.ValueString(); got != "dest_123" { - t.Fatalf("state id = %q, want created dest_123 kept", got) + if !resp.Diagnostics.HasError() { + t.Fatal("expected status error") + } + if got, want := stateModel(t, resp.State), model(t); got != want { + t.Fatalf("state = %+v, want created destination %+v kept", got, want) + } + }) } } @@ -277,7 +290,7 @@ func TestReadRefreshesState(t *testing.T) { get: func(ctx context.Context, id string) (*kernel.AuditLogExportDestination, error) { gotID = id return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { - d.Status = kernel.AuditLogExportDestinationStatusPaused + d.Status = kernel.AuditLogExportDestinationStatusActive }) }, }) @@ -289,11 +302,14 @@ func TestReadRefreshesState(t *testing.T) { if resp.Diagnostics.HasError() { t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) } + if got := stateModel(t, resp.State).Prefix; !got.Equal(prior.Prefix) { + t.Fatalf("prefix = %v, want %v", got, prior.Prefix) + } if gotID != "dest_123" { t.Fatalf("get id = %q, want dest_123", gotID) } - if got := stateModel(t, resp.State).Status.ValueString(); got != "paused" { - t.Fatalf("status = %q, want paused", got) + if got := stateModel(t, resp.State).Status.ValueString(); got != "active" { + t.Fatalf("status = %q, want active", got) } } @@ -347,7 +363,7 @@ func TestUpdateSendsPatch(t *testing.T) { update: func(ctx context.Context, id string, params kernel.AuditLogExportDestinationUpdateParams) (*kernel.AuditLogExportDestination, error) { gotID = id gotBody = marshal(t, params) - return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { d.Prefix = "audit/" }) + return destinationResponse(t, func(d *kernel.AuditLogExportDestination) { d.Prefix = "audit" }) }, }) prior := model(t) diff --git a/internal/resources/auditlogexportdestination/schema.go b/internal/resources/auditlogexportdestination/schema.go index 99c5b40..b499775 100644 --- a/internal/resources/auditlogexportdestination/schema.go +++ b/internal/resources/auditlogexportdestination/schema.go @@ -23,6 +23,7 @@ func destinationSchema() rschema.Schema { return rschema.Schema{ MarkdownDescription: "Kernel audit-log export destination. Kernel continuously delivers the organization's audit logs to an S3 bucket: its role, `kernel_role_arn`, assumes your `role_arn` with `external_id` as the `sts:ExternalId` condition. " + "Destinations are organization-scoped, require an organization-scoped API key, and require a Kernel plan that includes audit logs. " + + "New destinations are paused. Create the destination, configure the role's trust policy using the computed values, verify destination access, then set `status = \"active\"` and apply again. " + "Delivery starts when the destination is activated; events recorded before activation or while paused are never exported. " + "Replacing a destination issues a new `external_id`, so the role's trust policy has to be updated to match.", Attributes: map[string]rschema.Attribute{ @@ -84,7 +85,7 @@ func destinationSchema() rschema.Schema { "status": rschema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "`active` or `paused`. Pausing stops delivery, and resuming starts again from the time of the resume. When omitted, Terraform does not manage the status.", + MarkdownDescription: "`active` or `paused`. Pausing stops delivery, and resuming starts again from the time of the resume. When omitted, Terraform does not manage the status and new destinations remain paused.", Validators: []validator.String{ stringvalidator.OneOf(statusActive, statusPaused), },