From 204a648d5783ba76560ce308c2f6567ce30927d6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 04:43:37 -0400 Subject: [PATCH 1/2] refactor(iac): delete bespoke in-core infra-admin surface (superseded by step.iac_provider_* + plugin SPA) Removes the entire iac/admin/ package tree (audit, catalog, handler, proto, testdata, ui assets), module/infra_admin*.go (5 files), cmd/wfctl/infra_admin*.go (3 files), and engine_infra_admin_test.go. The factory registration and schema.RegisterModuleType("infra.admin") are removed from engine.go. The `wfctl infra admin` subcommand and its usage line are removed from infra.go. The proto-vendor-staleness CI workflow and its script are deleted. Makefile targets test-integration-admin and vendor-infra-proto are removed. iac/wfctlhelpers/desired_hash_test.go: drops TestDesiredStateHash_MatchesHandlerInlined (cross-package divergence guard against handler.DesiredHash, which is gone). Also folds in: fix(schema): declare iac_provider_drift 'reason' output. Adds {Key:"reason", Type:"string", Description:"Why drift detection is unsupported (set only when supported=false)"} to the step.iac_provider_drift Outputs in schema/step_schema_builtins.go, matching the key the step emits on the ErrProviderMethodUnimplemented path. Files deleted: 59 (iac/admin/* 46, module/infra_admin* 5, cmd/wfctl/infra_admin* 3, engine_infra_admin_test.go 1, .github/workflows/proto-vendor-staleness.yml 1, scripts/check-vendored-proto.sh 1, plus 2 Makefile targets). Files modified: engine.go, cmd/wfctl/infra.go, Makefile, iac/wfctlhelpers/desired_hash_test.go, schema/step_schema_builtins.go. GOWORK=off go build ./... && go test ./...: all green, 0 lint issues. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/proto-vendor-staleness.yml | 43 - Makefile | 38 +- cmd/wfctl/infra.go | 5 - cmd/wfctl/infra_admin.go | 701 ----- cmd/wfctl/infra_admin_audit_test.go | 149 - cmd/wfctl/infra_admin_parity_test.go | 290 -- engine.go | 11 - engine_infra_admin_test.go | 58 - iac/admin/audit/writer.go | 212 -- iac/admin/audit/writer_test.go | 288 -- iac/admin/catalog/catalog.go | 196 -- .../catalog/catalog_proto_parity_test.go | 110 - iac/admin/catalog/catalog_test.go | 60 - iac/admin/catalog/engines.go | 70 - iac/admin/catalog/engines_test.go | 80 - iac/admin/catalog/fields.go | 468 ---- iac/admin/catalog/fields_audit_test.go | 129 - iac/admin/catalog/naming.go | 72 - iac/admin/catalog/regions.go | 88 - iac/admin/catalog/regions_test.go | 83 - iac/admin/handler/apply_resource.go | 278 -- iac/admin/handler/apply_resource_test.go | 236 -- iac/admin/handler/authz.go | 76 - iac/admin/handler/destroy_resource.go | 126 - iac/admin/handler/destroy_resource_test.go | 119 - iac/admin/handler/drift_check.go | 64 - iac/admin/handler/drift_check_test.go | 80 - iac/admin/handler/generate_config.go | 210 -- iac/admin/handler/generate_config_test.go | 303 -- iac/admin/handler/get_resource.go | 129 - iac/admin/handler/get_resource_test.go | 251 -- iac/admin/handler/list_providers.go | 123 - iac/admin/handler/list_providers_test.go | 377 --- iac/admin/handler/list_resources.go | 134 - iac/admin/handler/list_resources_test.go | 403 --- iac/admin/handler/list_types.go | 91 - iac/admin/handler/list_types_fqn_test.go | 95 - iac/admin/handler/list_types_test.go | 105 - iac/admin/handler/plan_resource.go | 197 -- iac/admin/handler/plan_resource_test.go | 124 - iac/admin/proto/infra_admin.pb.go | 2480 ----------------- iac/admin/proto/infra_admin.proto | 429 --- iac/admin/proto/proto_roundtrip_test.go | 566 ---- iac/admin/testdata/infra.proto | 167 -- iac/admin/ui.go | 25 - iac/admin/ui_dist/actions.html | 76 - iac/admin/ui_dist/actions.js | 182 -- iac/admin/ui_dist/new.html | 42 - iac/admin/ui_dist/new.js | 450 --- iac/admin/ui_dist/resource.html | 92 - iac/admin/ui_dist/resource.js | 409 --- iac/admin/ui_dist/resources.html | 26 - iac/admin/ui_dist/resources.js | 110 - iac/admin/ui_dist/styles.css | 28 - iac/admin/ui_test.go | 233 -- iac/wfctlhelpers/desired_hash_test.go | 56 - module/infra_admin.go | 1262 --------- module/infra_admin_clock.go | 9 - module/infra_admin_integration_test.go | 623 ----- .../infra_admin_mutation_integration_test.go | 482 ---- module/infra_admin_test.go | 1568 ----------- schema/step_schema_builtins.go | 1 + scripts/check-vendored-proto.sh | 116 - 63 files changed, 2 insertions(+), 16102 deletions(-) delete mode 100644 .github/workflows/proto-vendor-staleness.yml delete mode 100644 cmd/wfctl/infra_admin.go delete mode 100644 cmd/wfctl/infra_admin_audit_test.go delete mode 100644 cmd/wfctl/infra_admin_parity_test.go delete mode 100644 engine_infra_admin_test.go delete mode 100644 iac/admin/audit/writer.go delete mode 100644 iac/admin/audit/writer_test.go delete mode 100644 iac/admin/catalog/catalog.go delete mode 100644 iac/admin/catalog/catalog_proto_parity_test.go delete mode 100644 iac/admin/catalog/catalog_test.go delete mode 100644 iac/admin/catalog/engines.go delete mode 100644 iac/admin/catalog/engines_test.go delete mode 100644 iac/admin/catalog/fields.go delete mode 100644 iac/admin/catalog/fields_audit_test.go delete mode 100644 iac/admin/catalog/naming.go delete mode 100644 iac/admin/catalog/regions.go delete mode 100644 iac/admin/catalog/regions_test.go delete mode 100644 iac/admin/handler/apply_resource.go delete mode 100644 iac/admin/handler/apply_resource_test.go delete mode 100644 iac/admin/handler/authz.go delete mode 100644 iac/admin/handler/destroy_resource.go delete mode 100644 iac/admin/handler/destroy_resource_test.go delete mode 100644 iac/admin/handler/drift_check.go delete mode 100644 iac/admin/handler/drift_check_test.go delete mode 100644 iac/admin/handler/generate_config.go delete mode 100644 iac/admin/handler/generate_config_test.go delete mode 100644 iac/admin/handler/get_resource.go delete mode 100644 iac/admin/handler/get_resource_test.go delete mode 100644 iac/admin/handler/list_providers.go delete mode 100644 iac/admin/handler/list_providers_test.go delete mode 100644 iac/admin/handler/list_resources.go delete mode 100644 iac/admin/handler/list_resources_test.go delete mode 100644 iac/admin/handler/list_types.go delete mode 100644 iac/admin/handler/list_types_fqn_test.go delete mode 100644 iac/admin/handler/list_types_test.go delete mode 100644 iac/admin/handler/plan_resource.go delete mode 100644 iac/admin/handler/plan_resource_test.go delete mode 100644 iac/admin/proto/infra_admin.pb.go delete mode 100644 iac/admin/proto/infra_admin.proto delete mode 100644 iac/admin/proto/proto_roundtrip_test.go delete mode 100644 iac/admin/testdata/infra.proto delete mode 100644 iac/admin/ui.go delete mode 100644 iac/admin/ui_dist/actions.html delete mode 100644 iac/admin/ui_dist/actions.js delete mode 100644 iac/admin/ui_dist/new.html delete mode 100644 iac/admin/ui_dist/new.js delete mode 100644 iac/admin/ui_dist/resource.html delete mode 100644 iac/admin/ui_dist/resource.js delete mode 100644 iac/admin/ui_dist/resources.html delete mode 100644 iac/admin/ui_dist/resources.js delete mode 100644 iac/admin/ui_dist/styles.css delete mode 100644 iac/admin/ui_test.go delete mode 100644 module/infra_admin.go delete mode 100644 module/infra_admin_clock.go delete mode 100644 module/infra_admin_integration_test.go delete mode 100644 module/infra_admin_mutation_integration_test.go delete mode 100644 module/infra_admin_test.go delete mode 100755 scripts/check-vendored-proto.sh diff --git a/.github/workflows/proto-vendor-staleness.yml b/.github/workflows/proto-vendor-staleness.yml deleted file mode 100644 index 50059f01..00000000 --- a/.github/workflows/proto-vendor-staleness.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Proto Vendor Staleness - -# Fails when iac/admin/testdata/infra.proto drifts from the upstream -# GoCodeAlone/workflow-plugin-infra proto at the pinned source tag. -# Refresh procedure: `make vendor-infra-proto` + update Source version header. -# -# Why this matters: catalog_proto_parity_test.go (iac/admin/catalog/) asserts -# every *Config message in the vendored proto has a catalog entry. If the -# vendored proto silently lags the upstream, new resource types added by -# workflow-plugin-infra land in production without field-spec catalog coverage. - -on: - push: - branches: [ main ] - paths: - - 'iac/admin/testdata/infra.proto' - - 'scripts/check-vendored-proto.sh' - - '.github/workflows/proto-vendor-staleness.yml' - pull_request: - branches: [ main ] - paths: - - 'iac/admin/testdata/infra.proto' - - 'scripts/check-vendored-proto.sh' - - '.github/workflows/proto-vendor-staleness.yml' - # Also run on a weekly schedule to catch upstream drift even when the - # vendored file isn't touched locally. - schedule: - - cron: '0 6 * * 1' # Mondays 06:00 UTC - workflow_dispatch: - -permissions: - contents: read - -jobs: - check-vendored-proto: - name: Check vendored infra.proto against upstream - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check vendored proto staleness - run: bash scripts/check-vendored-proto.sh diff --git a/Makefile b/Makefile index c08fb997..fc1bda90 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-ui build-go test bench bench-baseline bench-compare lint fmt vet fix install-hooks clean ko-build build-wfctl vendor-infra-proto test-integration-admin +.PHONY: build build-ui build-go test bench bench-baseline bench-compare lint fmt vet fix install-hooks clean ko-build build-wfctl # Common benchmark flags BENCH_FLAGS = -bench=. -benchmem -run=^$$ -timeout=30m @@ -52,24 +52,6 @@ lint: echo "workflow#699 guard: rpc Apply correctly absent"; \ fi -# Run the T17 host-module integration test that exercises the live -# workflow-plugin-admin gRPC plugin subprocess. The test itself -# (module/infra_admin_integration_test.go) probes for the sibling -# repo at ../workflow-plugin-admin and skips when absent — this -# target makes the dependency explicit + lets CI pass an env var -# to point at a pre-checked-out clone. Per -# docs/plans/2026-05-27-infra-admin-dynamic.md Task 17. -# -# Usage: -# make test-integration-admin # uses ../workflow-plugin-admin -# WORKFLOW_PLUGIN_ADMIN_PATH=/path make ... # explicit override -test-integration-admin: - @if [ ! -f "$${WORKFLOW_PLUGIN_ADMIN_PATH:-../workflow-plugin-admin}/go.mod" ]; then \ - echo "workflow-plugin-admin not found at $${WORKFLOW_PLUGIN_ADMIN_PATH:-../workflow-plugin-admin}; set WORKFLOW_PLUGIN_ADMIN_PATH or checkout the sibling repo"; \ - exit 1; \ - fi - GOWORK=off go test -run TestInfraAdmin_IntegrationWithLiveAdminPlugin -v ./module/ - # Format code fmt: go fmt ./... @@ -109,24 +91,6 @@ run-admin: build ko-build: KO_DOCKER_REPO=ko.local ko build ./cmd/server --bare --platform=linux/$(shell go env GOARCH) -# Refresh the vendored workflow-plugin-infra proto descriptor used by -# the FieldSpec catalog parity test (iac/admin/catalog/ -# catalog_proto_parity_test.go). Run on every minor upstream -# workflow-plugin-infra release; then update the `Source version:` -# header inside iac/admin/testdata/infra.proto to match the new tag. -# -# Assumes workflow-plugin-infra is checked out as a workspace sibling -# (../workflow-plugin-infra) per the workspace convention. -vendor-infra-proto: - @if [ ! -f ../workflow-plugin-infra/internal/contracts/infra.proto ]; then \ - echo "vendor-infra-proto: ../workflow-plugin-infra/internal/contracts/infra.proto not found"; \ - exit 1; \ - fi - @printf '// Vendored from GoCodeAlone/workflow-plugin-infra/internal/contracts/infra.proto\n// Source version: TODO-update-tag (sourced %s)\n// Refresh via: make vendor-infra-proto\n// Drift detection: catalog_proto_parity_test.go\n\n' "$$(date +%Y-%m-%d)" > iac/admin/testdata/infra.proto - @cat ../workflow-plugin-infra/internal/contracts/infra.proto >> iac/admin/testdata/infra.proto - @echo "Vendored infra.proto refreshed at iac/admin/testdata/infra.proto." - @echo " -> update the 'Source version:' header to the upstream tag now." - # Clean build artifacts clean: rm -f server diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 5e7ff183..afacbc1a 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -113,8 +113,6 @@ func runInfra(args []string) error { return fmt.Errorf("audit-state-secrets exited with code %d", rc) } return nil - case "admin": - return runInfraAdmin(args[1:]) default: return infraUsage() } @@ -141,9 +139,6 @@ Actions: align Validate IaC config + plan alignment (8 rule families) test Hermetically validate expected infra config and plan outcomes security-check Scan plan.json for security policy violations - admin Query the infra.admin host-side module surface - (list-resources, get-resource, list-types, - list-providers, generate-config, audit-tail) cleanup Tag-based force-cleanup across providers (--tag NAME [--fix]) owners List cloud resources carrying a provider ownership marker audit-secrets Report provider_credential anti-patterns in secrets.generate diff --git a/cmd/wfctl/infra_admin.go b/cmd/wfctl/infra_admin.go deleted file mode 100644 index 4b62320d..00000000 --- a/cmd/wfctl/infra_admin.go +++ /dev/null @@ -1,701 +0,0 @@ -package main - -// wfctl infra admin — CLI mirror of the host-side infra.admin module's -// typed RPC surface. Each subcommand resolves the iac.state backend + -// iac.provider modules from a workflow config via wfctlhelpers (T1-T3), -// invokes the shared handler library (T5/T6), and renders the output. -// -// The CLI is the second half of the design's "handler library imported -// by both module HTTP routes and wfctl CLI subcommands" contract — the -// same handler functions back HTTP and CLI so behavior cannot drift. -// -// Subcommands (per plan §Task 19): -// -// wfctl infra admin list-resources [--type T] [--provider P] -// [--app-context CTX] [--env E] -// [--format json|table] [-c FILE] -// wfctl infra admin get-resource NAME [--env E] [--format json|table] -// [-c FILE] -// wfctl infra admin list-types [--provider P] -// [--format json|json-schema|table] -// [-c FILE] -// wfctl infra admin list-providers [--env E] [-c FILE] -// wfctl infra admin generate-config --type T --name N -// --provider P [--field K=V...] -// [-c FILE] -// wfctl infra admin audit-tail --base-url URL [--since DUR] -// [--format json|table] -// -// Authz: the CLI runs as the operator with full filesystem access to -// the workflow config; AdminAuthzEvidence is implicitly satisfied -// (authz_checked=true, authz_allowed=true). The handler library still -// runs its default-deny guard, so we ALWAYS populate evidence — the -// authz layer above the CLI is the OS filesystem permission on the -// config file, not a separate token check. -// -// Plan §Task 20 covers the parity test asserting JSON output decodes -// as the matching adminpb proto type. - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "sort" - "strings" - "text/tabwriter" - "time" - - "github.com/GoCodeAlone/workflow/config" - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" - "github.com/GoCodeAlone/workflow/interfaces" - "google.golang.org/protobuf/encoding/protojson" -) - -// infraAdminEvidence returns the AdminAuthzEvidence stamp every -// CLI-side invocation needs. CLI operators have already been vetted by -// filesystem ACL on the config file; the handler library's default- -// deny guard still requires the evidence to be populated. -// -// Subject preference per implementer-1's T19 guidance: the OS-side -// USER env var when present, falling back to a static "wfctl-cli" -// sentinel. The env var is best-effort (operators can spoof it); its -// only purpose is improving the audit-log breadcrumb for routine -// CLI use, NOT as an authz primitive. Authz is the filesystem ACL on -// the config file. -func infraAdminEvidence() *adminpb.AdminAuthzEvidence { - subject := os.Getenv("USER") - if subject == "" { - subject = "wfctl-cli" - } - return &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: subject, - GrantedPermissions: []string{"infra:read"}, - } -} - -// infraAdminFormat is the shared --format value type. Accepts a small -// allowlist to keep usage predictable. -type infraAdminFormat string - -const ( - infraAdminFormatJSON infraAdminFormat = "json" - infraAdminFormatJSONSchema infraAdminFormat = "json-schema" - infraAdminFormatTable infraAdminFormat = "table" -) - -func runInfraAdmin(args []string) error { - if len(args) < 1 { - return infraAdminUsage() - } - switch args[0] { - case "list-resources": - return runInfraAdminListResources(args[1:]) - case "get-resource": - return runInfraAdminGetResource(args[1:]) - case "list-types": - return runInfraAdminListTypes(args[1:]) - case "list-providers": - return runInfraAdminListProviders(args[1:]) - case "generate-config": - return runInfraAdminGenerateConfig(args[1:]) - case "audit-tail": - return runInfraAdminAuditTail(args[1:]) - case "--help", "-h", "help": - return infraAdminUsage() - default: - fmt.Fprintf(os.Stderr, "wfctl infra admin: unknown subcommand %q\n\n", args[0]) - return infraAdminUsage() - } -} - -func infraAdminUsage() error { - fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl infra admin [options] - -Mirror of the infra.admin host-side module's typed RPC surface. Reads -the iac.state backend + iac.provider modules from a workflow config -and renders results. - -Subcommands: - list-resources [--type T] [--provider P] [--app-context CTX] [--env E] - [--format json|table] [-c FILE] - get-resource NAME [--env E] [--format json|table] [-c FILE] - list-types [--provider P] [--format json|json-schema|table] [-c FILE] - list-providers [--env E] [-c FILE] - generate-config --type T --name N --provider P [--field K=V ...] - [-c FILE] - audit-tail --base-url URL [--since DUR] [--format json|table] - -Common flags: - -c FILE Path to workflow config (default: workflow.yaml). - Required for all subcommands except audit-tail. - --env NAME Per-environment backend overrides applied to iac.state. - --format VAL Output format (json or table). audit-tail emits ndjson - on stdout when --format=json. -`) - return nil -} - -// --- dependency resolution ------------------------------------------------ - -// adminDeps bundles the three things every read subcommand needs to -// invoke the handler library: state backend, provider map (keyed by -// host YAML module name), the catalog triple, and the -// providerTypeByModule lookup populated from the YAML config at -// resolve-time (per design cycle-5/6 + spec-reviewer T6 F1 — the -// YAML-config `provider:` field is the stable identifier the -// catalogs key against; provider.Name() returns the plugin's -// display name and is NOT a stable identifier). -type adminDeps struct { - store interfaces.IaCStateStore - providers map[string]interfaces.IaCProvider - providerTypeByModule map[string]string - closers []io.Closer - fieldCatalog *catalog.FieldSpecCatalog - regionCatalog *catalog.RegionCatalog - engineCatalog *catalog.EngineCatalog -} - -// resolveAdminDeps loads the workflow config and instantiates everything -// the handler library needs. envName is forwarded to ResolveStateStore -// for per-env backend overrides. -func resolveAdminDeps(ctx context.Context, cfgFile, envName string) (*adminDeps, error) { - if cfgFile == "" { - return nil, errors.New("config file path required (-c FILE)") - } - store, err := wfctlhelpers.ResolveStateStore(cfgFile, envName, currentInfraPluginDir) - if err != nil { - return nil, fmt.Errorf("resolve state store: %w", err) - } - providers, closers, err := wfctlhelpers.LoadAllIaCProvidersFromConfig(ctx, cfgFile) - if err != nil { - return nil, fmt.Errorf("load providers: %w", err) - } - providerTypeByModule, err := loadProviderTypeByModule(cfgFile) - if err != nil { - // Roll back the loaded providers so we don't leak subprocesses. - for _, c := range closers { - _ = c.Close() - } - return nil, fmt.Errorf("load providerTypeByModule: %w", err) - } - return &adminDeps{ - store: store, - providers: providers, - providerTypeByModule: providerTypeByModule, - closers: closers, - fieldCatalog: catalog.New(), - regionCatalog: catalog.NewRegionCatalog(), - engineCatalog: catalog.NewEngineCatalog(), - }, nil -} - -// loadProviderTypeByModule walks cfgFile's modules and returns a map -// of {iac.provider module name -> YAML `provider:` string}. Per -// spec-reviewer T6 F1 + design cycle-5/6: this is the captured-at- -// Init contract handler.ListProviders relies on for the -// provider_type field in AdminProviderSummary. T15 (host module) -// will populate the same map from its app.GetService-resolved -// modules; the CLI side reads it from disk. -func loadProviderTypeByModule(cfgFile string) (map[string]string, error) { - cfg, err := config.LoadFromFile(cfgFile) - if err != nil { - return nil, fmt.Errorf("load %s: %w", cfgFile, err) - } - out := map[string]string{} - for i := range cfg.Modules { - m := &cfg.Modules[i] - if m.Type != "iac.provider" { - continue - } - modCfg := config.ExpandEnvInMap(m.Config) - pt, _ := modCfg["provider"].(string) - if pt == "" { - // Skip silently — matches LoadAllIaCProvidersFromConfig's - // same-shape behavior; misconfigured module already won't - // have a provider entry in the providers map either. - continue - } - out[m.Name] = pt - } - return out, nil -} - -func (d *adminDeps) Close() { - for _, c := range d.closers { - if c != nil { - _ = c.Close() - } - } -} - -// --- subcommand: list-resources ------------------------------------------- - -func runInfraAdminListResources(args []string) error { - fs := flag.NewFlagSet("infra admin list-resources", flag.ContinueOnError) - cfg := fs.String("c", "workflow.yaml", "config file path") - typeFilter := fs.String("type", "", "filter by resource type (e.g. infra.vpc)") - providerFilter := fs.String("provider", "", "filter by provider module name") - appContext := fs.String("app-context", "", "filter by app_context label") - envName := fs.String("env", "", "per-env backend overrides") - format := fs.String("format", string(infraAdminFormatJSON), "json or table") - if err := fs.Parse(args); err != nil { - return err - } - - ctx := context.Background() - deps, err := resolveAdminDeps(ctx, *cfg, *envName) - if err != nil { - return err - } - defer deps.Close() - - in := &adminpb.AdminListResourcesInput{ - TypeFilter: *typeFilter, - ProviderFilter: *providerFilter, - AppContextFilter: *appContext, - EnvName: *envName, - Evidence: infraAdminEvidence(), - } - out, err := handler.ListResources(ctx, deps.store, deps.providers, deps.fieldCatalog, in) - if err != nil { - return err - } - if out.Error != "" { - return errors.New(out.Error) - } - - switch infraAdminFormat(*format) { - case infraAdminFormatJSON: - return emitJSON(out) - case infraAdminFormatTable: - return renderResourcesTable(out.Resources) - default: - return fmt.Errorf("--format %q not supported (use json or table)", *format) - } -} - -func renderResourcesTable(rows []*adminpb.AdminResourceSummary) error { - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - defer func() { _ = w.Flush() }() - fmt.Fprintln(w, "NAME\tTYPE\tPROVIDER\tSTATUS\tUPDATED") - for _, r := range rows { - updated := "" - if r.UpdatedAtUnix > 0 { - updated = time.Unix(r.UpdatedAtUnix, 0).UTC().Format(time.RFC3339) - } - providerCell := r.ProviderModule - if r.ProviderType != "" { - providerCell = providerCell + " / " + r.ProviderType - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - r.Name, r.Type, providerCell, r.Status, updated) - } - return nil -} - -// --- subcommand: get-resource --------------------------------------------- - -func runInfraAdminGetResource(args []string) error { - fs := flag.NewFlagSet("infra admin get-resource", flag.ContinueOnError) - cfg := fs.String("c", "workflow.yaml", "config file path") - envName := fs.String("env", "", "per-env backend overrides") - format := fs.String("format", string(infraAdminFormatJSON), "json or table") - if err := fs.Parse(args); err != nil { - return err - } - if fs.NArg() < 1 { - return errors.New("get-resource: NAME positional argument required") - } - name := fs.Arg(0) - - ctx := context.Background() - deps, err := resolveAdminDeps(ctx, *cfg, *envName) - if err != nil { - return err - } - defer deps.Close() - - in := &adminpb.AdminGetResourceInput{ - Name: name, - EnvName: *envName, - Evidence: infraAdminEvidence(), - } - out, err := handler.GetResource(ctx, deps.store, in) - if err != nil { - return err - } - if out.Error != "" { - return errors.New(out.Error) - } - - switch infraAdminFormat(*format) { - case infraAdminFormatJSON: - return emitJSON(out) - case infraAdminFormatTable: - return renderResourceDetail(out.Resource) - default: - return fmt.Errorf("--format %q not supported (use json or table)", *format) - } -} - -func renderResourceDetail(r *adminpb.AdminResourceDetail) error { - if r == nil { - fmt.Println("(empty)") - return nil - } - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - s := r.Summary - if s != nil { - fmt.Fprintf(w, "Name:\t%s\n", s.Name) - fmt.Fprintf(w, "Type:\t%s\n", s.Type) - fmt.Fprintf(w, "Provider module:\t%s\n", s.ProviderModule) - fmt.Fprintf(w, "Provider type:\t%s\n", s.ProviderType) - fmt.Fprintf(w, "Provider id:\t%s\n", s.ProviderId) - fmt.Fprintf(w, "Status:\t%s\n", s.Status) - fmt.Fprintf(w, "App context:\t%s\n", s.AppContext) - if s.UpdatedAtUnix > 0 { - fmt.Fprintf(w, "Updated:\t%s\n", time.Unix(s.UpdatedAtUnix, 0).UTC().Format(time.RFC3339)) - } - fmt.Fprintf(w, "Dependencies:\t%s\n", strings.Join(s.Dependencies, ", ")) - } - fmt.Fprintf(w, "Config hash:\t%s\n", r.ConfigHash) - if len(r.SensitiveOutputsRedacted) > 0 { - fmt.Fprintf(w, "Redacted outputs:\t%s\n", strings.Join(r.SensitiveOutputsRedacted, ", ")) - } - if err := w.Flush(); err != nil { - return err - } - if len(r.AppliedConfigJson) > 0 { - fmt.Println("\nApplied config:") - fmt.Println(string(r.AppliedConfigJson)) - } - if len(r.OutputsJson) > 0 { - fmt.Println("\nOutputs (redacted):") - fmt.Println(string(r.OutputsJson)) - } - return nil -} - -// --- subcommand: list-types ----------------------------------------------- - -func runInfraAdminListTypes(args []string) error { - fs := flag.NewFlagSet("infra admin list-types", flag.ContinueOnError) - cfg := fs.String("c", "workflow.yaml", "config file path") - providerFilter := fs.String("provider", "", "filter by provider") - envName := fs.String("env", "", "per-env backend overrides") - format := fs.String("format", string(infraAdminFormatJSON), "json, json-schema, or table") - if err := fs.Parse(args); err != nil { - return err - } - - ctx := context.Background() - deps, err := resolveAdminDeps(ctx, *cfg, *envName) - if err != nil { - return err - } - defer deps.Close() - - in := &adminpb.AdminListResourceTypesInput{ - ProviderFilter: *providerFilter, - Evidence: infraAdminEvidence(), - } - out, err := handler.ListResourceTypes(ctx, deps.fieldCatalog, deps.providers, in) - if err != nil { - return err - } - if out.Error != "" { - return errors.New(out.Error) - } - - switch infraAdminFormat(*format) { - case infraAdminFormatJSON, infraAdminFormatJSONSchema: - return emitJSON(out) - case infraAdminFormatTable: - return renderTypesTable(out.Types) - default: - return fmt.Errorf("--format %q not supported (use json, json-schema, or table)", *format) - } -} - -func renderTypesTable(types []*adminpb.AdminResourceTypeMetadata) error { - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - defer func() { _ = w.Flush() }() - fmt.Fprintln(w, "TYPE\tFIELDS\tFQN") - for _, t := range types { - names := make([]string, 0, len(t.Fields)) - for _, f := range t.Fields { - names = append(names, f.Name) - } - fmt.Fprintf(w, "%s\t%s\t%s\n", t.Type, strings.Join(names, ","), t.ConfigMessageFqn) - } - return nil -} - -// --- subcommand: list-providers ------------------------------------------- - -func runInfraAdminListProviders(args []string) error { - fs := flag.NewFlagSet("infra admin list-providers", flag.ContinueOnError) - cfg := fs.String("c", "workflow.yaml", "config file path") - envName := fs.String("env", "", "per-env backend overrides") - format := fs.String("format", string(infraAdminFormatJSON), "json or table") - if err := fs.Parse(args); err != nil { - return err - } - - ctx := context.Background() - deps, err := resolveAdminDeps(ctx, *cfg, *envName) - if err != nil { - return err - } - defer deps.Close() - - in := &adminpb.AdminListProvidersInput{ - EnvName: *envName, - Evidence: infraAdminEvidence(), - } - out, err := handler.ListProviders(ctx, deps.providers, deps.providerTypeByModule, deps.fieldCatalog, deps.regionCatalog, deps.engineCatalog, in) - if err != nil { - return err - } - if out.Error != "" { - return errors.New(out.Error) - } - - switch infraAdminFormat(*format) { - case infraAdminFormatJSON: - return emitJSON(out) - case infraAdminFormatTable: - return renderProvidersTable(out.Providers) - default: - return fmt.Errorf("--format %q not supported (use json or table)", *format) - } -} - -func renderProvidersTable(providers []*adminpb.AdminProviderSummary) error { - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - defer func() { _ = w.Flush() }() - fmt.Fprintln(w, "MODULE\tTYPE\tREGIONS\tENGINES\tREGIONS_SOURCE") - for _, p := range providers { - fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", - p.ModuleName, p.ProviderType, - len(p.SupportedRegions), len(p.SupportedEngines), - p.RegionsSource) - } - return nil -} - -// --- subcommand: generate-config ------------------------------------------ - -// fieldFlag implements flag.Value for repeated --field KEY=VALUE flags. -// Multiple invocations append to the captured map; duplicate keys are -// last-write-wins (consistent with form-builder submit behavior where -// later inputs override earlier). -type fieldFlag struct { - values map[string]string -} - -func newFieldFlag() *fieldFlag { - return &fieldFlag{values: map[string]string{}} -} - -func (f *fieldFlag) String() string { - if f == nil || len(f.values) == 0 { - return "" - } - keys := make([]string, 0, len(f.values)) - for k := range f.values { - keys = append(keys, k) - } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+f.values[k]) - } - return strings.Join(parts, ",") -} - -func (f *fieldFlag) Set(s string) error { - i := strings.Index(s, "=") - if i <= 0 { - return fmt.Errorf("--field must be KEY=VALUE, got %q", s) - } - if f.values == nil { - f.values = map[string]string{} - } - f.values[s[:i]] = s[i+1:] - return nil -} - -func runInfraAdminGenerateConfig(args []string) error { - fs := flag.NewFlagSet("infra admin generate-config", flag.ContinueOnError) - cfg := fs.String("c", "workflow.yaml", "config file path") - rType := fs.String("type", "", "resource type (e.g. infra.vpc)") - rName := fs.String("name", "", "resource name") - provider := fs.String("provider", "", "provider module name") - envName := fs.String("env", "", "per-env backend overrides") - fields := newFieldFlag() - fs.Var(fields, "field", "field KEY=VALUE (repeatable; array values are JSON-encoded e.g. --field ports='[80,443]')") - if err := fs.Parse(args); err != nil { - return err - } - if *rType == "" || *rName == "" || *provider == "" { - return errors.New("generate-config: --type, --name, --provider all required") - } - - ctx := context.Background() - deps, err := resolveAdminDeps(ctx, *cfg, *envName) - if err != nil { - return err - } - defer deps.Close() - - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: *rType, - ResourceName: *rName, - ProviderModule: *provider, - FieldValues: fields.values, - Evidence: infraAdminEvidence(), - } - out, err := handler.GenerateConfig(ctx, deps.fieldCatalog, in) - if err != nil { - return err - } - if out.Error != "" { - return errors.New(out.Error) - } - if len(out.ValidationErrors) > 0 { - for _, e := range out.ValidationErrors { - fmt.Fprintf(os.Stderr, "validation error: %s\n", e) - } - return fmt.Errorf("generate-config returned %d validation error(s)", len(out.ValidationErrors)) - } - fmt.Print(out.YamlSnippet) - if !strings.HasSuffix(out.YamlSnippet, "\n") { - fmt.Println() - } - return nil -} - -// --- subcommand: audit-tail ----------------------------------------------- - -// runInfraAdminAuditTail does NOT load a config — it talks HTTP to a -// running infra.admin module instance via its /api/infra-admin/audit -// endpoint. Per design §Security Review row "Access logging". -// -// The endpoint streams newline-delimited AdminAuditEntry proto-JSON. -// We pass --format=json through unchanged (forward the body bytes); -// --format=table parses each line and renders a compact view. -func runInfraAdminAuditTail(args []string) error { - fs := flag.NewFlagSet("infra admin audit-tail", flag.ContinueOnError) - baseURL := fs.String("base-url", "", "host base URL (e.g. https://admin.example.com)") - since := fs.Duration("since", 0, "tail entries newer than this duration (e.g. 1h, 24h)") - format := fs.String("format", string(infraAdminFormatJSON), "json or table") - if err := fs.Parse(args); err != nil { - return err - } - if *baseURL == "" { - return errors.New("audit-tail: --base-url required") - } - - u, err := url.Parse(*baseURL) - if err != nil { - return fmt.Errorf("--base-url: %w", err) - } - u.Path = strings.TrimRight(u.Path, "/") + "/api/infra-admin/audit" - q := u.Query() - if *since > 0 { - q.Set("since", fmt.Sprintf("%d", time.Now().Add(-*since).Unix())) - } - u.RawQuery = q.Encode() - - resp, err := http.Get(u.String()) //nolint:gosec // operator-supplied base URL - if err != nil { - return fmt.Errorf("audit-tail GET: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode/100 != 2 { - return fmt.Errorf("audit-tail: HTTP %d", resp.StatusCode) - } - - switch infraAdminFormat(*format) { - case infraAdminFormatJSON: - _, err := io.Copy(os.Stdout, resp.Body) - return err - case infraAdminFormatTable: - return renderAuditTable(resp.Body) - default: - return fmt.Errorf("--format %q not supported (use json or table)", *format) - } -} - -func renderAuditTable(body io.Reader) error { - w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - defer func() { _ = w.Flush() }() - fmt.Fprintln(w, "TS\tSUBJECT\tACTION\tRESULT\tTARGETS") - - // Per code-reviewer T19+T20 I-2 + cross-task contract: the audit - // endpoint emits AdminAuditEntry protojson (T14 writer uses - // protojson.MarshalOptions{UseProtoNames: true} on disk; T15's - // audit-tail HTTP handler will stream those lines verbatim). - // protojson encodes int64 fields (e.g. ts_unix) as DECIMAL - // STRINGS for JS BigInt safety — `encoding/json` decoding into a - // Go int64 field rejects the string with "json: cannot unmarshal - // string into Go struct field". Per-line protojson.Unmarshal is - // the only correct decoder. - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var entry adminpb.AdminAuditEntry - if err := protojson.Unmarshal([]byte(line), &entry); err != nil { - return fmt.Errorf("decode audit entry (line %q): %w", line, err) - } - ts := "" - if entry.TsUnix > 0 { - ts = time.Unix(entry.TsUnix, 0).UTC().Format(time.RFC3339) - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - ts, entry.Subject, entry.Action, entry.Result, strings.Join(entry.Targets, ",")) - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("read audit body: %w", err) - } - return nil -} - -// --- shared output helper ------------------------------------------------- - -// emitJSON writes the proto message as indented JSON to stdout. Used -// by every --format=json branch. We use encoding/json with Go struct -// tags (snake_case via the proto-generated json tags) for the -// AdminListResourcesOutput / etc. shapes. The parity test (T20) asserts -// json.Unmarshal of stdout into the matching adminpb type round-trips. -// -// We intentionally do NOT use protojson here. The CLI's wire is JSON -// over stdout; UseProtoNames vs camelCase divergence isn't a concern -// because both the encoder (Go's encoding/json with proto-generated -// snake_case tags) and the parity-test decoder (same) operate on the -// same struct tag set. The HTTP module on the host side uses protojson -// because there the wire is HTTP and the JS client expects snake_case; -// here the wire is the same Go process round-trip and using Go's -// stdlib keeps the dependency surface minimal. -func emitJSON(v any) error { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(v) -} diff --git a/cmd/wfctl/infra_admin_audit_test.go b/cmd/wfctl/infra_admin_audit_test.go deleted file mode 100644 index ddaba948..00000000 --- a/cmd/wfctl/infra_admin_audit_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package main - -import ( - "bytes" - "os" - "strings" - "testing" - "time" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "google.golang.org/protobuf/encoding/protojson" -) - -// TestRenderAuditTable_DecodesProtojsonNdjson is the regression -// guard for code-reviewer T19+T20 I-2: the CLI's audit-tail body -// decoder MUST handle protojson's int64-as-decimal-string wire -// convention. An earlier draft used encoding/json which rejected -// `"ts_unix": "1234567890"` (string form) when decoding into -// AdminAuditEntry's int64 TsUnix field. This test feeds a -// protojson-encoded fixture (the same shape T14's writer emits + -// T15's audit-tail HTTP endpoint serves) and asserts the decoder -// round-trips every line + renders the expected table columns. -func TestRenderAuditTable_DecodesProtojsonNdjson(t *testing.T) { - // Build two AdminAuditEntry fixtures and serialise via protojson — - // mimics exactly what T14's audit.Writer emits to disk and what - // T15's audit-tail HTTP handler will stream over ndjson. - entries := []*adminpb.AdminAuditEntry{ - { - SchemaVersion: 1, - TsUnix: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC).Unix(), - Subject: "user:alice", - Action: "list_resources", - Result: "ok", - AppContext: "web", - }, - { - SchemaVersion: 1, - TsUnix: time.Date(2026, 5, 27, 12, 0, 1, 0, time.UTC).Unix(), - Subject: "user:bob", - Action: "get_resource", - Targets: []string{"vpc-prod"}, - Result: "ok", - AppContext: "api", - }, - } - opts := protojson.MarshalOptions{UseProtoNames: true} - var body bytes.Buffer - for _, e := range entries { - data, err := opts.Marshal(e) - if err != nil { - t.Fatalf("protojson.Marshal fixture: %v", err) - } - body.Write(data) - body.WriteByte('\n') - } - - // Sanity: the fixture must actually carry the int64-as-string - // form for ts_unix — this is the bit `encoding/json` chokes on. - // If protojson ever changes encoding rules, this assertion - // catches it so the decoder swap can be re-evaluated. - if !strings.Contains(body.String(), `"ts_unix":"`) { - t.Fatalf("fixture missing int64-as-string ts_unix encoding — protojson convention changed?\n%s", body.String()) - } - - // Redirect stdout for renderAuditTable. We don't assert on the - // exact rendered bytes (column widths depend on tabwriter); we - // only assert the decode loop completes + the row count matches - // the fixture count. - oldStdout := opensTabwriterStdout(t) - defer oldStdout.restore() - if err := renderAuditTable(&body); err != nil { - t.Fatalf("renderAuditTable: %v", err) - } - out := oldStdout.captured() - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - // One header + 2 data rows = 3 lines. - if len(lines) != 3 { - t.Fatalf("got %d lines, want 3 (header + 2 entries):\n%s", len(lines), out) - } - if !strings.Contains(lines[0], "TS") || !strings.Contains(lines[0], "SUBJECT") || !strings.Contains(lines[0], "ACTION") { - t.Errorf("header row missing expected columns: %q", lines[0]) - } - if !strings.Contains(lines[1], "user:alice") || !strings.Contains(lines[1], "list_resources") { - t.Errorf("row 1 missing expected content: %q", lines[1]) - } - if !strings.Contains(lines[2], "user:bob") || !strings.Contains(lines[2], "vpc-prod") { - t.Errorf("row 2 missing expected content: %q", lines[2]) - } -} - -// TestRenderAuditTable_HandlesEmptyBody guards against decoder -// crashes on an empty endpoint response (e.g. no entries since -// --since cutoff). -func TestRenderAuditTable_HandlesEmptyBody(t *testing.T) { - oldStdout := opensTabwriterStdout(t) - defer oldStdout.restore() - if err := renderAuditTable(strings.NewReader("")); err != nil { - t.Errorf("renderAuditTable on empty body: %v", err) - } - out := oldStdout.captured() - // Header line should still print so operators see column shape - // even when there are no entries. - if !strings.Contains(out, "TS") { - t.Errorf("header missing on empty body: %q", out) - } -} - -// opensTabwriterStdout swaps os.Stdout for a buffer for the test -// duration. renderAuditTable writes to os.Stdout directly, so we -// can't inject a writer without refactoring the function — the -// stdout redirect is the minimum-invasive approach. -type stdoutCapture struct { - prev *os.File - r, w *os.File - done chan struct{} - collect *bytes.Buffer -} - -func opensTabwriterStdout(t *testing.T) *stdoutCapture { - t.Helper() - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe: %v", err) - } - prev := os.Stdout - os.Stdout = w - c := &stdoutCapture{ - prev: prev, - r: r, - w: w, - done: make(chan struct{}), - collect: &bytes.Buffer{}, - } - go func() { - _, _ = c.collect.ReadFrom(c.r) - close(c.done) - }() - return c -} - -func (c *stdoutCapture) captured() string { - _ = c.w.Close() - <-c.done - return c.collect.String() -} - -func (c *stdoutCapture) restore() { - os.Stdout = c.prev -} diff --git a/cmd/wfctl/infra_admin_parity_test.go b/cmd/wfctl/infra_admin_parity_test.go deleted file mode 100644 index 793ffa6b..00000000 --- a/cmd/wfctl/infra_admin_parity_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package main - -// Plan §Task 20 parity test for the wfctl infra admin CLI surface. -// -// What's covered here (in-process, no exec): -// - The CLI's JSON output round-trips through the matching adminpb -// proto-message via encoding/json (the CLI uses encoding/json with -// proto-generated snake_case tags; see emitJSON godoc on the -// "we intentionally don't use protojson" rationale). -// - The subcommand dispatcher rejects unknown subcommands without -// panicking and emits a stable usage on `--help`. -// - --field flag parses repeated KEY=VALUE pairs with last-write-wins -// semantics. -// -// What's NOT covered here (intentionally — exec-driven full smoke -// lives in workflow-scenarios/scenarios/92-infra-admin-demo/test/run.sh -// per plan §CLI end-to-end smoke): -// - Live state-store + provider resolution (requires a real workflow -// config + filesystem state). The scenario harness exercises that -// path against the docker-compose stack. -// - audit-tail HTTP round-trip (scenario harness via curl). -// -// The CLI's JSON output uses snake_case field names (the proto- -// generated `json:"snake_case,omitempty"` tags on adminpb structs). -// json.Unmarshal of the bytes back into the same struct must produce -// an equivalent value — that's the parity assertion. - -import ( - "bytes" - "encoding/json" - "flag" - "strings" - "testing" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" -) - -// captureEmit invokes emitJSON's path by marshaling the same way the -// CLI does to a buffer instead of stdout. We mirror the body of -// emitJSON here to keep the test hermetic (no os.Stdout redirection -// race in parallel-test mode). -func captureEmit(t *testing.T, v any) []byte { - t.Helper() - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetIndent("", " ") - if err := enc.Encode(v); err != nil { - t.Fatalf("encode: %v", err) - } - return buf.Bytes() -} - -func TestInfraAdminCLI_ListResourcesOutput_RoundTrip(t *testing.T) { - original := &adminpb.AdminListResourcesOutput{ - Resources: []*adminpb.AdminResourceSummary{ - { - Name: "demo-vpc", - Type: "infra.vpc", - ProviderModule: "do-provider", - ProviderType: "digitalocean", - ProviderId: "vpc-abc123", - Status: "applied", - UpdatedAtUnix: 1717000000, - Dependencies: []string{"infra.firewall"}, - AppContext: "production", - }, - }, - } - payload := captureEmit(t, original) - - var decoded adminpb.AdminListResourcesOutput - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) - } - if len(decoded.Resources) != 1 { - t.Fatalf("decoded.Resources len=%d, want 1", len(decoded.Resources)) - } - got := decoded.Resources[0] - want := original.Resources[0] - if got.Name != want.Name || got.Type != want.Type || - got.ProviderModule != want.ProviderModule || - got.ProviderType != want.ProviderType || - got.ProviderId != want.ProviderId || - got.Status != want.Status || - got.UpdatedAtUnix != want.UpdatedAtUnix || - got.AppContext != want.AppContext { - t.Errorf("AdminResourceSummary round-trip lost fields.\n got=%+v\nwant=%+v", got, want) - } - if len(got.Dependencies) != 1 || got.Dependencies[0] != "infra.firewall" { - t.Errorf("dependencies lost: got=%v", got.Dependencies) - } -} - -func TestInfraAdminCLI_GetResourceOutput_RoundTrip(t *testing.T) { - original := &adminpb.AdminGetResourceOutput{ - Resource: &adminpb.AdminResourceDetail{ - Summary: &adminpb.AdminResourceSummary{Name: "demo-db", Type: "infra.database"}, - AppliedConfigJson: []byte(`{"engine":"postgres"}`), - OutputsJson: []byte(`{"endpoint":"db.example.com"}`), - ConfigHash: "sha256:abc", - LastDriftCheckUnix: 1717000123, - SensitiveOutputsRedacted: []string{"password"}, - }, - } - payload := captureEmit(t, original) - - var decoded adminpb.AdminGetResourceOutput - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) - } - if decoded.Resource == nil { - t.Fatal("decoded.Resource is nil") - } - if decoded.Resource.ConfigHash != original.Resource.ConfigHash { - t.Errorf("ConfigHash: got %q, want %q", decoded.Resource.ConfigHash, original.Resource.ConfigHash) - } - if string(decoded.Resource.AppliedConfigJson) != string(original.Resource.AppliedConfigJson) { - t.Errorf("AppliedConfigJson round-trip mismatch: got=%s want=%s", - decoded.Resource.AppliedConfigJson, original.Resource.AppliedConfigJson) - } - if len(decoded.Resource.SensitiveOutputsRedacted) != 1 || - decoded.Resource.SensitiveOutputsRedacted[0] != "password" { - t.Errorf("SensitiveOutputsRedacted round-trip mismatch: got=%v", decoded.Resource.SensitiveOutputsRedacted) - } -} - -func TestInfraAdminCLI_ListTypesOutput_RoundTrip(t *testing.T) { - original := &adminpb.AdminListResourceTypesOutput{ - Types: []*adminpb.AdminResourceTypeMetadata{ - { - Type: "infra.vpc", - // Plural "plugins" + acronym-preserving VPC per T6 F2 fix - // (commit 8ac54ca84). Matches real handler output now. - ConfigMessageFqn: "workflow.plugins.infra.v1.VPCConfig", - Fields: []*adminpb.AdminFieldSpec{ - {Name: "provider", Kind: "enum_dynamic", EnumSource: "providers", Required: true}, - {Name: "cidr", Kind: "string", Required: true}, - }, - }, - }, - } - payload := captureEmit(t, original) - - var decoded adminpb.AdminListResourceTypesOutput - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) - } - if len(decoded.Types) != 1 || decoded.Types[0].Type != "infra.vpc" { - t.Errorf("types round-trip mismatch: got=%+v", decoded.Types) - } - if len(decoded.Types[0].Fields) != 2 { - t.Errorf("fields lost: got=%d, want 2", len(decoded.Types[0].Fields)) - } - if decoded.Types[0].Fields[0].EnumSource != "providers" { - t.Errorf("EnumSource lost: got=%q", decoded.Types[0].Fields[0].EnumSource) - } -} - -func TestInfraAdminCLI_ListProvidersOutput_RoundTrip(t *testing.T) { - original := &adminpb.AdminListProvidersOutput{ - Providers: []*adminpb.AdminProviderSummary{ - { - ModuleName: "do-provider", - ProviderType: "digitalocean", - Capabilities: []string{"plan", "apply"}, - SupportedRegions: []string{"nyc1", "nyc3"}, - SupportedTypes: []string{"infra.vpc", "infra.database"}, - SupportedEngines: []string{"postgres", "mysql"}, - RegionsSource: "local-catalog", - }, - }, - } - payload := captureEmit(t, original) - - var decoded adminpb.AdminListProvidersOutput - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) - } - if len(decoded.Providers) != 1 { - t.Fatalf("decoded.Providers len=%d, want 1", len(decoded.Providers)) - } - p := decoded.Providers[0] - if p.ModuleName != "do-provider" || p.ProviderType != "digitalocean" || - p.RegionsSource != "local-catalog" { - t.Errorf("provider summary fields lost: got=%+v", p) - } - if len(p.SupportedRegions) != 2 || p.SupportedRegions[0] != "nyc1" { - t.Errorf("SupportedRegions lost: got=%v", p.SupportedRegions) - } - if len(p.SupportedEngines) != 2 { - t.Errorf("SupportedEngines lost: got=%v", p.SupportedEngines) - } -} - -func TestInfraAdminCLI_GenerateConfigOutput_RoundTrip(t *testing.T) { - original := &adminpb.AdminGenerateConfigOutput{ - YamlSnippet: "name: demo-vpc\ntype: infra.vpc\nconfig:\n cidr: 10.0.0.0/16\n", - } - payload := captureEmit(t, original) - - var decoded adminpb.AdminGenerateConfigOutput - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) - } - if decoded.YamlSnippet != original.YamlSnippet { - t.Errorf("YamlSnippet round-trip mismatch.\n got=%q\nwant=%q", - decoded.YamlSnippet, original.YamlSnippet) - } -} - -func TestInfraAdminCLI_UnknownSubcommand(t *testing.T) { - // Cases that should not panic and should return without crashing. - // Output goes to os.Stderr / flag.CommandLine — we only assert - // the dispatcher behavior. - cases := [][]string{ - {}, // no args → usage - {"--help"}, // help → usage - {"-h"}, // -h → usage - {"help"}, // help → usage - {"completely-bogus"}, // unknown → usage - } - for _, args := range cases { - // Discard errors: --help and unknown both return nil (usage - // path) per the dispatcher implementation, and the no-arg - // case returns nil too. Just confirm no panic. - _ = runInfraAdmin(args) - } -} - -func TestInfraAdminCLI_FieldFlag_RepeatableAndLastWriteWins(t *testing.T) { - f := newFieldFlag() - if err := f.Set("provider=do-provider"); err != nil { - t.Fatalf("Set: %v", err) - } - if err := f.Set("region=nyc1"); err != nil { - t.Fatalf("Set: %v", err) - } - if err := f.Set("region=nyc3"); err != nil { // overwrite - t.Fatalf("Set: %v", err) - } - if got := f.values["provider"]; got != "do-provider" { - t.Errorf("provider: got %q, want do-provider", got) - } - if got := f.values["region"]; got != "nyc3" { - t.Errorf("region last-write-wins: got %q, want nyc3", got) - } - if !strings.Contains(f.String(), "provider=do-provider") { - t.Errorf("String() missing provider entry: %q", f.String()) - } - // Bad input: missing `=` should error rather than panic. - if err := f.Set("no-equal"); err == nil { - t.Error("Set(\"no-equal\") returned nil, want error") - } - // Empty key (leading `=`) should also error — the index check is - // `<= 0`, so `=val` (idx=0) is rejected. - if err := f.Set("=val"); err == nil { - t.Error("Set(\"=val\") returned nil, want error") - } -} - -// TestInfraAdminCLI_HelpListsAllSubcommands ensures the dispatcher's -// usage block stays in sync with the 6 documented subcommands. The -// usage text is the source of truth users see when typing `wfctl infra -// admin --help`; missing entries here surface as quiet UX failures. -func TestInfraAdminCLI_HelpListsAllSubcommands(t *testing.T) { - // Capture flag.CommandLine output into a buffer so we can grep. - var buf bytes.Buffer - origOut := flag.CommandLine.Output() - flag.CommandLine.SetOutput(&buf) - defer flag.CommandLine.SetOutput(origOut) - - if err := infraAdminUsage(); err != nil { - t.Fatalf("infraAdminUsage: %v", err) - } - out := buf.String() - - expected := []string{ - "list-resources", - "get-resource", - "list-types", - "list-providers", - "generate-config", - "audit-tail", - } - for _, name := range expected { - if !strings.Contains(out, name) { - t.Errorf("usage missing subcommand %q in output:\n%s", name, out) - } - } -} diff --git a/engine.go b/engine.go index 22832897..c56a38b4 100644 --- a/engine.go +++ b/engine.go @@ -172,17 +172,6 @@ func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine { )) } - // T18: register the engine-side infra.admin module factory so the - // host loads `type: infra.admin` without requiring a plugin. Per - // docs/plans/2026-05-27-infra-admin-dynamic.md + ADR-0002 the - // admin surface is engine-side rather than a plugin contribution, - // so the factory belongs here in NewStdEngine alongside the - // step.workflow_call registration. Also registers with the - // schema's known-module-types so TestSchemaKnowsPluginModuleTypes - // stays green and config-validation accepts `type: infra.admin`. - const infraAdminType = "infra.admin" - e.moduleFactories[infraAdminType] = module.NewInfraAdmin - schema.RegisterModuleType(infraAdminType) return e } diff --git a/engine_infra_admin_test.go b/engine_infra_admin_test.go deleted file mode 100644 index 58d40c33..00000000 --- a/engine_infra_admin_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package workflow - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/module" -) - -// TestEngineFactory_InfraAdminRegistered pins T18: the engine-side -// infra.admin module factory MUST be registered by NewStdEngine -// so the host loads `type: infra.admin` without requiring a -// plugin. Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 18. -// -// Reads the factory map via the public RegisteredModuleTypes -// surface AND constructs an instance to assert the factory -// actually produces a *module.InfraAdmin (not nil, not a fake -// type). -func TestEngineFactory_InfraAdminRegistered(t *testing.T) { - e := NewStdEngine(nil, nopLogger{}) - types := e.RegisteredModuleTypes() - found := false - for _, ty := range types { - if ty == "infra.admin" { - found = true - break - } - } - if !found { - t.Fatalf("infra.admin not in RegisteredModuleTypes (got %v)", types) - } - - // Construct an instance through the factory to verify the - // factory closure produces a real module — not a panic, not nil. - factory := e.moduleFactories["infra.admin"] - if factory == nil { - t.Fatal("moduleFactories[infra.admin] is nil even though listed") - } - mod := factory("test-infra-admin", map[string]any{}) - if mod == nil { - t.Fatal("factory returned nil module") - } - if _, ok := mod.(*module.InfraAdmin); !ok { - t.Errorf("factory returned %T, want *module.InfraAdmin", mod) - } - if mod.Name() != "test-infra-admin" { - t.Errorf("module Name = %q, want test-infra-admin", mod.Name()) - } -} - -// nopLogger is the minimum modular.Logger for engine tests. -// Production wires the slog adapter; we just want a non-nil -// logger so NewStdEngine doesn't crash. -type nopLogger struct{} - -func (nopLogger) Debug(string, ...any) {} -func (nopLogger) Info(string, ...any) {} -func (nopLogger) Warn(string, ...any) {} -func (nopLogger) Error(string, ...any) {} diff --git a/iac/admin/audit/writer.go b/iac/admin/audit/writer.go deleted file mode 100644 index 8fc8efa7..00000000 --- a/iac/admin/audit/writer.go +++ /dev/null @@ -1,212 +0,0 @@ -// Package audit hosts the JSONL audit-log writer used by the host-side -// infra.admin workflow module to record every admin action (read or -// future-mutating). Writer is concurrent-safe, append-only, and -// reopens its file handle on SIGHUP so external log-rotation tools -// (logrotate, etc.) can move the file aside without losing -// subsequent entries. -// -// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Security Review -// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 14) -// -// **Wire format**: protojson over workflow.iac.v1.AdminAuditEntry. -// Per design §Access logging: "Each line is AdminAuditEntry -// proto-JSON. Reader `wfctl infra admin audit-tail --base-url ...` -// (HTTP-backed)". Writing via protojson preserves the strict-contract -// invariant that on-disk lines are byte-identical to what the HTTP -// audit-tail endpoint serves, so the CLI's protojson.Unmarshal -// decoder works end-to-end. -// -// Earlier T14 draft (commit 42b9e1c11) defined an Entry struct with -// 10 plan-listed fields including `ts time.Time`, `action_id`, -// `dry_run`, `confirm_destroy` — but the design proto AdminAuditEntry -// has only 7 fields and uses `ts_unix int64`. Per spec-reviewer T14 -// F1 + strict-interpretation invariant ("design wins when plan/ -// design diverge"), Entry is now a thin alias for the proto message -// with no host-only extras. If v1.1 needs the extras, that's an -// ADR-tracked schema amendment, not a quiet plan-extra add. -package audit - -import ( - "errors" - "fmt" - "os" - "os/signal" - "sync" - "syscall" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "google.golang.org/protobuf/encoding/protojson" -) - -// Entry is a re-export of the proto AdminAuditEntry message so -// audit-package callers don't have to import the adminpb package -// directly. The writer's Write method marshals via protojson, so -// any field added to the proto becomes available to writers -// without an audit-package code change. -// -// Strict contract: this MUST stay an alias rather than a parallel -// struct definition. The spec-reviewer T14 F1 follow-up moved from -// a host-only struct to this alias precisely to eliminate the -// drift surface between the writer + the typed wire shape. -type Entry = adminpb.AdminAuditEntry - -// marshalOpts is the single protojson marshaling configuration the -// writer uses. UseProtoNames=true emits snake_case JSON keys -// matching the .proto field names (the same configuration T15's -// writeProto helper uses for HTTP responses), so the on-disk JSONL -// shape matches what the HTTP audit-tail endpoint will serve. -var marshalOpts = protojson.MarshalOptions{UseProtoNames: true} - -// Writer wraps an append-only JSONL file with concurrent-safe writes -// and SIGHUP reopen. The host module (T15) holds one Writer for the -// lifetime of the infra.admin module; tests can create + close them -// at will. -// -// Close-safety: double-Close is a no-op. Post-Close Write returns a -// clear error rather than silently dropping audit entries — losing -// audit data is worse than a noisy error per design Security Review. -// -// SIGHUP handling: the writer registers a signal handler on Open -// that reopens the file path under the mutex. External rotation -// (logrotate, mv + SIGHUP) works without losing in-flight writes. -type Writer struct { - path string - - mu sync.Mutex - file *os.File - closed bool - - sigC chan os.Signal - done chan struct{} -} - -// Open creates or appends-to the audit file at path and starts the -// SIGHUP-reopen goroutine. Per design Security Review: a non-nil -// error MUST be treated as FATAL at module Init — silently failing -// to open the audit log is the opposite of the "default-audit- -// everything" posture the design mandates. The caller (T15 module -// Init) propagates Open errors up as a module-init failure. -func Open(path string) (*Writer, error) { - if path == "" { - return nil, errors.New("audit.Open: empty path") - } - // 0o600 (owner-only) per gosec G302 + design Security Review's - // "audit logs MUST NOT be world-readable" stance — even the - // host's syslog group should not have read access without an - // explicit operator decision. - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return nil, fmt.Errorf("audit.Open %q: %w", path, err) - } - w := &Writer{ - path: path, - file: f, - sigC: make(chan os.Signal, 1), - done: make(chan struct{}), - } - // Register SIGHUP handler. signal.Notify is goroutine-safe and - // multiple writers in the same process all receive the signal - // (each reopens its own file). Stop() in Close() unregisters. - signal.Notify(w.sigC, syscall.SIGHUP) - go w.reopenLoop() - return w, nil -} - -// reopenLoop is the background SIGHUP-reopen goroutine. Runs until -// done is closed by Close(). -func (w *Writer) reopenLoop() { - for { - select { - case <-w.sigC: - w.reopen() - case <-w.done: - return - } - } -} - -// reopen closes the current file handle (if any) and opens a fresh -// handle at the original path. Called from the SIGHUP handler -// goroutine. Errors during reopen are not propagated (no caller is -// listening) but a future enhancement could emit a stderr line so -// operators see the failure. For now, log via fmt.Fprintln so the -// host process's stderr captures it. -func (w *Writer) reopen() { - w.mu.Lock() - defer w.mu.Unlock() - if w.closed { - return - } - if w.file != nil { - _ = w.file.Close() - } - f, err := os.OpenFile(w.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // 0o600 per gosec G302; see Open - if err != nil { - fmt.Fprintf(os.Stderr, "audit.Writer: SIGHUP reopen %q failed: %v (subsequent writes will error)\n", w.path, err) - w.file = nil - return - } - w.file = f -} - -// Write serializes the entry to one protojson line + newline and -// appends it under the mutex. Closed-after returns a clear error -// rather than silently dropping the entry — losing audit data is -// worse than a noisy error per design Security Review. -// -// SchemaVersion is set to 1 on the caller-provided entry before -// marshaling so callers cannot accidentally emit a different -// version. If the schema ever bumps to 2, this is the single -// change-point. -// -// The entry is taken by pointer because adminpb.AdminAuditEntry -// (the alias target) holds internal protobuf state; passing by -// value would copy that state and trigger a vet warning. -func (w *Writer) Write(e *Entry) error { - if e == nil { - return errors.New("audit.Write: nil entry") - } - e.SchemaVersion = 1 - data, err := marshalOpts.Marshal(e) - if err != nil { - return fmt.Errorf("audit.Write: protojson marshal: %w", err) - } - - w.mu.Lock() - defer w.mu.Unlock() - if w.closed { - return errors.New("audit.Write: writer is closed") - } - if w.file == nil { - return errors.New("audit.Write: writer has no file handle (SIGHUP reopen failed earlier)") - } - if _, err := w.file.Write(append(data, '\n')); err != nil { - return fmt.Errorf("audit.Write: %w", err) - } - return nil -} - -// Close stops the SIGHUP goroutine, unregisters the signal handler, -// and closes the file handle. Double-Close is a no-op; post-Close -// Write returns a clear error. -func (w *Writer) Close() error { - w.mu.Lock() - if w.closed { - w.mu.Unlock() - return nil - } - w.closed = true - file := w.file - w.file = nil - w.mu.Unlock() - - // Stop the goroutine + unregister the signal handler. signal.Stop - // is goroutine-safe; the channel close signals reopenLoop to exit. - signal.Stop(w.sigC) - close(w.done) - - if file != nil { - return file.Close() - } - return nil -} diff --git a/iac/admin/audit/writer_test.go b/iac/admin/audit/writer_test.go deleted file mode 100644 index 10e5021b..00000000 --- a/iac/admin/audit/writer_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package audit_test - -import ( - "bufio" - "os" - "path/filepath" - "strings" - "sync" - "syscall" - "testing" - "time" - - "github.com/GoCodeAlone/workflow/iac/admin/audit" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "google.golang.org/protobuf/encoding/protojson" -) - -// TestOpen_CreatesFileIfMissing pins that Open creates the audit -// file on first run rather than erroring on ENOENT — the host -// module's first start with a fresh access_log_path config must -// succeed. -func TestOpen_CreatesFileIfMissing(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer w.Close() - if _, err := os.Stat(path); err != nil { - t.Errorf("expected audit file at %s, stat err: %v", path, err) - } -} - -// TestOpen_FatalOnDirPath verifies the design's "FATAL on open -// failure" contract surfaces a clear error rather than silently -// swallowing. Passing an existing directory triggers an OS-level -// open error. -func TestOpen_FatalOnDirPath(t *testing.T) { - dir := t.TempDir() // existing directory; opening it as a regular file fails - _, err := audit.Open(dir) - if err == nil { - t.Fatal("expected Open to error when path is a directory, got nil") - } -} - -// TestWrite_AppendsOneProtojsonLineWithSchemaVersion1 pins the -// wire shape per design + spec-reviewer T14 F1: each Write emits -// exactly one protojson line carrying schema_version:1. Multiple -// writes append; lines do not overlap. Per-line round-trip -// through protojson.Unmarshal into AdminAuditEntry asserts the -// contract end-to-end (this is the test that would catch the -// earlier draft's schema-mismatch bug). -func TestWrite_AppendsOneProtojsonLineWithSchemaVersion1(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - entries := []*audit.Entry{ - { - TsUnix: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC).Unix(), - Subject: "user:alice", - Action: "list_resources", - Result: "ok", - AppContext: "web", - }, - { - TsUnix: time.Date(2026, 5, 27, 12, 0, 1, 0, time.UTC).Unix(), - Subject: "user:bob", - Action: "get_resource", - Targets: []string{"vpc-prod"}, - Result: "ok", - AppContext: "api", - }, - } - for _, e := range entries { - if err := w.Write(e); err != nil { - t.Fatalf("Write: %v", err) - } - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), string(data)) - } - for i, line := range lines { - // Round-trip through protojson against the actual proto - // message. This is the contract guard: a future regression - // to encoding/json or to a non-proto struct will fail here. - var got adminpb.AdminAuditEntry - if err := protojson.Unmarshal([]byte(line), &got); err != nil { - t.Fatalf("line %d: protojson.Unmarshal into AdminAuditEntry: %v\n%s", i, err, line) - } - if got.SchemaVersion != 1 { - t.Errorf("line %d schema_version = %d, want 1", i, got.SchemaVersion) - } - // snake_case key shape: assert literal "ts_unix" / "schema_version" appear. - if !strings.Contains(line, "\"schema_version\"") { - t.Errorf("line %d missing snake_case schema_version key: %s", i, line) - } - if !strings.Contains(line, "\"ts_unix\"") { - t.Errorf("line %d missing snake_case ts_unix key (writer must use UseProtoNames=true): %s", i, line) - } - } -} - -// TestWrite_ConcurrentAppendsAreSerialised pins the mutex around -// the write path so two goroutines writing simultaneously don't -// interleave bytes. We launch N writers; final line count == N AND -// every line is valid protojson (decoded back into AdminAuditEntry). -func TestWrite_ConcurrentAppendsAreSerialised(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - const writers = 32 - const writesEach = 16 - var wg sync.WaitGroup - for i := range writers { - wg.Add(1) - go func(id int) { - defer wg.Done() - for range writesEach { - _ = w.Write(&audit.Entry{ - TsUnix: time.Now().UTC().Unix(), - Subject: "user:test", - Action: "list_resources", - Result: "ok", - }) - } - }(i) - } - wg.Wait() - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - scanner := bufio.NewScanner(strings.NewReader(string(data))) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - count := 0 - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - var got adminpb.AdminAuditEntry - if err := protojson.Unmarshal([]byte(line), &got); err != nil { - t.Fatalf("line %d not valid protojson (interleaved write?): %v\n%s", count, err, line) - } - count++ - } - want := writers * writesEach - if count != want { - t.Errorf("got %d valid protojson lines, want %d (lost writes or interleaved bytes)", count, want) - } -} - -// TestSIGHUP_ReopensFileHandle verifies the SIGHUP-reopen contract -// from design Security Review: when an external log-rotation tool -// (logrotate, etc.) renames the audit file and sends SIGHUP, the -// writer reopens at the original path. Subsequent writes land in -// the NEW file (the original on-disk path), not in the moved -// inode the old fd still pointed at. -// -// Subject is used as the pre/post discriminator (the proto-aligned -// Entry doesn't carry an `action_id` field; subject is the closest -// per-entry label that survives the protojson round-trip). -func TestSIGHUP_ReopensFileHandle(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - // Write one entry pre-rotation. - if err := w.Write(&audit.Entry{Subject: "subject:pre", Action: "list_resources", Result: "ok"}); err != nil { - t.Fatal(err) - } - - // Simulate log-rotation: move the file aside. - rotated := path + ".1" - if err := os.Rename(path, rotated); err != nil { - t.Fatalf("rename to simulate rotation: %v", err) - } - - // Send SIGHUP to ourselves; the writer's signal handler should - // reopen `path` (creating a new file since the old name was - // renamed away). - if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil { - t.Fatalf("send SIGHUP: %v", err) - } - // Give the signal handler a moment to process. - time.Sleep(100 * time.Millisecond) - - // Write one entry post-rotation. - if err := w.Write(&audit.Entry{Subject: "subject:post", Action: "list_resources", Result: "ok"}); err != nil { - t.Fatalf("post-rotation Write: %v", err) - } - - // The rotated file holds only the pre-rotation entry. - preData, err := os.ReadFile(rotated) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(preData), `"subject":"subject:pre"`) { - t.Errorf("rotated file missing pre-rotation entry: %s", string(preData)) - } - if strings.Contains(string(preData), `"subject":"subject:post"`) { - t.Errorf("rotated file contains POST-rotation entry — SIGHUP reopen failed: %s", string(preData)) - } - - // The current (re-created) file holds only the post-rotation entry. - postData, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read post-rotation file: %v", err) - } - if !strings.Contains(string(postData), `"subject":"subject:post"`) { - t.Errorf("post-rotation file missing post-entry: %s", string(postData)) - } - if strings.Contains(string(postData), `"subject":"subject:pre"`) { - t.Errorf("post-rotation file contains pre-rotation entry — SIGHUP reopen wrote to wrong path: %s", string(postData)) - } -} - -// TestClose_IsIdempotent verifies double-Close doesn't panic or -// error — host module's Stop() path may race with shutdown signals. -func TestClose_IsIdempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatal(err) - } - if err := w.Close(); err != nil { - t.Errorf("first Close: %v", err) - } - if err := w.Close(); err != nil { - t.Errorf("second Close (should be no-op): %v", err) - } -} - -// TestWrite_AfterCloseReturnsError verifies post-Close Writes -// surface a clear error rather than silently dropping audit -// entries. -func TestWrite_AfterCloseReturnsError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatal(err) - } - w.Close() - if err := w.Write(&audit.Entry{Subject: "after-close"}); err == nil { - t.Error("expected Write after Close to error, got nil") - } -} - -// TestWrite_NilEntryReturnsError pins the defensive nil-guard so -// a future caller accidentally passing nil doesn't crash the host -// process — audit data integrity is preserved by surfacing the -// programming error. -func TestWrite_NilEntryReturnsError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "audit.jsonl") - w, err := audit.Open(path) - if err != nil { - t.Fatal(err) - } - defer w.Close() - if err := w.Write(nil); err == nil { - t.Error("expected Write(nil) to error, got nil") - } -} diff --git a/iac/admin/catalog/catalog.go b/iac/admin/catalog/catalog.go deleted file mode 100644 index 0948c12f..00000000 --- a/iac/admin/catalog/catalog.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package catalog hosts the host-side FieldSpec catalog (covers all 13 -// typed `infra.*` Configs from workflow-plugin-infra), the region -// catalog, and the engine catalog. The catalog drives the new-resource -// form-builder UI and feeds the typed AdminFieldSpec entries returned -// by InfraAdminService.ListResourceTypes (handler library T5/T6). -// -// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §FieldSpec Catalog -// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 7a skeleton; T7b entries; T8 region/engine) -// -// This file (T7a) provides the package skeleton: types, the -// FieldSpecCatalog struct, the New() constructor returning an empty -// catalog, and the Get / AllTypes / FreeformReason accessors. T7b -// fills the 13 typed-Config entries in catalog/fields.go in parallel -// after T7a lands. -// -// The skeleton was split out of T7 per plan-adversarial C1: Lane A's -// T5 handler library imports *catalog.FieldSpecCatalog as a typed -// parameter, so the package + type + New() must exist before T5 -// compiles. Splitting T7a → T7b resolves the hidden serial dependency. -package catalog - -import "sort" - -// FieldSpec describes one field of a typed `infra.*` Config so the -// new-resource form-builder UI can render the right input control. -// Mirrors workflow.iac.v1.AdminFieldSpec in iac/admin/proto/ -// infra_admin.proto field-for-field so the handler library (T5/T6) -// can copy between the two without coercion. See the proto file for -// the per-field semantics documentation; this struct's tags double -// as the wire-rename contract for any future protojson-friendly -// serialization. -type FieldSpec struct { - // Name is the YAML key the form-builder submits and the typed - // Config message field name (lower_snake_case to match proto). - Name string - - // Label is the human-readable form-field label. - Label string - - // Kind is one of {"enum", "enum_dynamic", "string", "number", - // "bool", "array_string", "array_object", "object"}. The - // freeform-audit test (T7b) enforces that every "string" / - // "array_string" entry carries a FREEFORM_OK reason via the - // package-level reasons map. - Kind string - - // Required indicates whether the form must collect a value. - Required bool - - // EnumValues is the fixed option list for Kind=="enum". - EnumValues []string - - // EnumSource is the dynamic-options provider for - // Kind=="enum_dynamic": one of {"providers", "regions", "sizes", - // "engines", "resource_types", "app_contexts", "k8s-versions"}. - // The form-builder fetches the options at render time. - EnumSource string - - // Description is the form-field help text (shown as tooltip or - // inline help in the new-resource UI). - Description string - - // DefaultValue is the initial form value (string-encoded per the - // proto's map field_values contract). - DefaultValue string - - // Sensitive indicates the form should render a masked input AND - // the value MUST be excluded from any rendered preview or audit - // log entry. - Sensitive bool - - // ElementKind is the per-element Kind for array_* entries. - ElementKind string - - // MinCount + MaxCount bound array_* entry counts. - MinCount int32 - MaxCount int32 - - // DependsOnField filters this field's enum_dynamic options by the - // value picked for another field (e.g. region options depend on - // the chosen provider). - DependsOnField string -} - -// FieldSpecCatalog is the hand-maintained registry mapping resource -// type names (e.g. "infra.vpc") to their per-field specs. The -// handler library (T5/T6) holds one instance for the lifetime of the -// host-side infra.admin module; the catalog is read-only after -// New() returns. -// -// T7a ships the catalog skeleton with an empty entries map; T7b -// populates the map alongside the parallel freeformReasons table. -type FieldSpecCatalog struct { - entries map[string][]FieldSpec -} - -// New returns an empty FieldSpecCatalog. Per plan §Task 7a the -// skeleton catalog has zero entries so T5/T6 handler library can -// compile against the typed parameter while T7b fills the 13 -// typed-Config entries in parallel. -// -// The returned catalog is safe for concurrent reads (Get / AllTypes -// never mutate state). T7b's filled catalog will populate entries at -// package init time (or via New() initialization) so no caller-side -// locking is required. -func New() *FieldSpecCatalog { - return &FieldSpecCatalog{ - entries: catalogEntries(), - } -} - -// Get returns the field specs for typeName and ok=true when the type -// is registered. Returns (nil, false) for unknown types so callers -// can distinguish a missing-type from a registered-but-empty type -// (both return zero-length slices on the proto side; the boolean -// is the discriminator). -func (c *FieldSpecCatalog) Get(typeName string) ([]FieldSpec, bool) { - fields, ok := c.entries[typeName] - if !ok { - return nil, false - } - // Defensive copy so callers cannot mutate the catalog's internal - // state. Cost is minimal — entry slices average ~6 fields per - // type. - out := make([]FieldSpec, len(fields)) - copy(out, fields) - return out, true -} - -// AllTypes returns the sorted list of registered resource-type names. -// The sort is deterministic so callers (handler library + -// ListResourceTypes RPC response) emit a stable ordering for -// snapshot tests + diff-friendly downstream consumers. -func (c *FieldSpecCatalog) AllTypes() []string { - out := make([]string, 0, len(c.entries)) - for k := range c.entries { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// catalogEntries is the package-level seam T7b uses to populate the -// catalog. The skeleton ships with no entries; T7b's fields.go will -// override this variable with the hand-written table mapping -// {VPCConfig, ContainerServiceConfig, K8SClusterConfig, ...} to -// their per-field specs. -// -// Exposed as a package-level var (not a const, not a function -// literal embedded in New) so T7b's fields.go can reassign it from -// an init() function in the same package — Go forbids redeclaring -// a package-level var of the same name across files, so T7b's -// fields.go MUST take this shape: -// -// package catalog -// func init() { -// catalogEntries = func() map[string][]FieldSpec { -// return map[string][]FieldSpec{ -// "infra.vpc": { ... }, -// // ... 13 typed Configs ... -// } -// } -// } -// -// Per spec-reviewer T7a comment-nit (commit ff0662602). -var catalogEntries = func() map[string][]FieldSpec { - return map[string][]FieldSpec{} -} - -// FreeformReason returns the FREEFORM_OK annotation reason for a -// catalog field whose Kind is "string" or "array_string". Returns -// ("", false) when no entry exists for {typeName, fieldName}. -// -// The plan §Task 7b audit test enumerates every catalog entry and -// asserts that every Kind ∈ {"string", "array_string"} field has a -// non-empty reason here. The reasons table is a parallel -// map[typeName]map[fieldName]string populated by hand alongside the -// catalog entries in T7b's fields.go. Skeleton ships with an empty -// map so the audit test runs (and trivially passes — there are no -// string-kind entries yet). -func FreeformReason(typeName, fieldName string) (string, bool) { - byField, ok := freeformReasons[typeName] - if !ok { - return "", false - } - reason, ok := byField[fieldName] - return reason, ok -} - -// freeformReasons is the parallel annotation table populated by T7b. -// Skeleton ships empty so the package compiles + the smoke test -// passes; T7b reassigns this from a same-package init() in fields.go -// (same mechanism + Go-redeclaration constraint as catalogEntries -// above — assign via init(), don't re-`var`-declare). Per -// spec-reviewer T7a comment-nit (commit ff0662602). -var freeformReasons = map[string]map[string]string{} diff --git a/iac/admin/catalog/catalog_proto_parity_test.go b/iac/admin/catalog/catalog_proto_parity_test.go deleted file mode 100644 index 0df61f48..00000000 --- a/iac/admin/catalog/catalog_proto_parity_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package catalog_test - -import ( - "os" - "regexp" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" -) - -// Allowlist of vendored *Config message names that are NOT directly -// instantiated as form-rendered types — InfraResourceConfig is the -// abstract base per the design's §FieldSpec Catalog note. -var protoConfigAllowlist = map[string]bool{ - "InfraResourceConfig": true, -} - -// configMessagePattern matches `message Config {` at line start, -// optionally preceded by indentation. The vendored proto is small and -// structurally regular (no nested message types share the *Config -// suffix in v1.0.0), so a regex suffices over a full protoparse. -// -// If a future contract introduces nested *Config messages or a more -// complex shape, swap to google.golang.org/protobuf/types/descriptorpb -// + a protoparse driver. -var configMessagePattern = regexp.MustCompile(`(?m)^\s*message\s+([A-Za-z0-9_]+Config)\s*\{`) - -// typeToConfigMessage is a thin shim onto the lifted shared helper -// catalog.ConfigMessageShortName (see naming.go). Kept as a local -// alias so existing T9 parity-test code reads unchanged; the actual -// mapping table moved to a non-test file per spec-reviewer T6 F2 -// (commit 1ea231fdd) so the T6 handler library can call the same -// algorithm rather than reimplementing it and drifting. -func typeToConfigMessage(typeName string) string { - return catalog.ConfigMessageShortName(typeName) -} - -func extractConfigMessages(t *testing.T, src string) []string { - t.Helper() - matches := configMessagePattern.FindAllStringSubmatch(src, -1) - out := make([]string, 0, len(matches)) - for _, m := range matches { - out = append(out, m[1]) - } - return out -} - -// TestCatalog_CoversAllTypedConfigs is the drift-detection backbone -// for the vendored proto. It walks every *Config message in the -// vendored workflow-plugin-infra/internal/contracts/infra.proto and -// asserts the catalog (T7b) has an entry for each (except the -// allowlisted abstract base). -// -// Failure modes this test catches: -// - Upstream adds a new typed Config (e.g. ServerlessFunctionConfig) -// and the vendored copy is refreshed without a paired catalog -// entry → test fails. -// - Catalog loses an entry → AllExpectedTypesRegistered already -// catches that, but this is the cross-repo guard. -func TestCatalog_CoversAllTypedConfigs(t *testing.T) { - data, err := os.ReadFile("../testdata/infra.proto") - if err != nil { - t.Fatalf("read vendored proto: %v", err) - } - - messages := extractConfigMessages(t, string(data)) - if len(messages) == 0 { - t.Fatal("regex extracted zero *Config messages — pattern or vendored file likely broken") - } - - cat := catalog.New() - coveredMessages := map[string]bool{} - for _, typeName := range cat.AllTypes() { - coveredMessages[typeToConfigMessage(typeName)] = true - } - - for _, msg := range messages { - if protoConfigAllowlist[msg] { - continue - } - if !coveredMessages[msg] { - t.Errorf("typed message %s is in vendored infra.proto but missing from FieldSpec catalog (typeToConfigMessage map; T7b)", msg) - } - } -} - -// TestCatalog_NoUncatalogedTypes is the reverse-direction guard: every -// catalog entry's Config message MUST exist in the vendored proto. -// This catches the case where a catalog entry is added pointing at a -// renamed / removed upstream Config. -func TestCatalog_NoUncatalogedTypes(t *testing.T) { - data, err := os.ReadFile("../testdata/infra.proto") - if err != nil { - t.Fatalf("read vendored proto: %v", err) - } - messages := extractConfigMessages(t, string(data)) - protoSet := map[string]bool{} - for _, m := range messages { - protoSet[m] = true - } - - cat := catalog.New() - for _, typeName := range cat.AllTypes() { - msg := typeToConfigMessage(typeName) - if !protoSet[msg] { - t.Errorf("catalog entry %q maps to %q which is NOT in vendored proto — upstream may have renamed/removed it", - typeName, msg) - } - } -} diff --git a/iac/admin/catalog/catalog_test.go b/iac/admin/catalog/catalog_test.go deleted file mode 100644 index 2792a2e0..00000000 --- a/iac/admin/catalog/catalog_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package catalog_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" -) - -// TestNew_ReturnsPopulatedCatalog pins the post-T7b contract: New() -// returns a usable catalog populated by fields.go's init() with the -// 13 typed `infra.*` Configs. The original T7a skeleton expected an -// empty catalog; T7b's fields.go reassigns catalogEntries from init() -// so callers of New() see the full table. Per plan §Task 7b. -func TestNew_ReturnsPopulatedCatalog(t *testing.T) { - cat := catalog.New() - if cat == nil { - t.Fatal("catalog.New() returned nil") - } - types := cat.AllTypes() - if len(types) == 0 { - t.Fatal("AllTypes() returned no entries; T7b should have populated 13 typed Configs") - } -} - -// TestGet_MissingTypeReturnsFalse pins the Get(missing) contract: the -// boolean must be false (not ok) when no entry exists for the given -// resource type. T5/T6 handler library code uses this signal to skip -// unknown types rather than crashing or returning empty results -// indistinguishable from a registered-but-empty type. -func TestGet_MissingTypeReturnsFalse(t *testing.T) { - cat := catalog.New() - fields, ok := cat.Get("infra.nonexistent") - if ok { - t.Errorf("Get(\"infra.nonexistent\") returned ok=true on empty catalog") - } - if fields != nil { - t.Errorf("Get on missing type returned %v, want nil slice", fields) - } -} - -// TestFreeformReason_MissingEntryReturnsFalse pins the package-level -// FreeformReason signature: when no FREEFORM_OK annotation exists for -// the requested {typeName, fieldName}, the function returns ("", false). -// -// Post-T7b inputs: use a genuinely missing pair (infra.vpc has no -// `nope` field) since T7b populated annotations for the previously -// empty (infra.vpc, cidr) pair used in the skeleton test. -func TestFreeformReason_MissingEntryReturnsFalse(t *testing.T) { - reason, ok := catalog.FreeformReason("infra.vpc", "nope_missing_field") - if ok { - t.Errorf("FreeformReason for missing field returned ok=true (reason=%q)", reason) - } - if reason != "" { - t.Errorf("FreeformReason returned reason=%q for missing field, want \"\"", reason) - } - // Cross-check: also asserts unknown typeName returns false. - if r, ok := catalog.FreeformReason("infra.unknown_type", "anything"); ok || r != "" { - t.Errorf("FreeformReason for unknown type returned ok=%v reason=%q, want false/\"\"", ok, r) - } -} diff --git a/iac/admin/catalog/engines.go b/iac/admin/catalog/engines.go deleted file mode 100644 index d0dab277..00000000 --- a/iac/admin/catalog/engines.go +++ /dev/null @@ -1,70 +0,0 @@ -// engines.go (T8) hosts the per-provider database/cache engine -// catalog used by the new-resource form-builder for enum_dynamic + -// EnumSource="engines" fields (infra.database.engine, etc.) that -// depend on the chosen provider. -// -// Like the region catalog, v1 is local-only. Same refresh cadence — -// review on every minor provider-plugin release. -// -// Last reviewed: 2026-05-27. - -package catalog - -import "sort" - -// EngineCatalog maps provider-type strings to their supported -// database engines. Cache engines are handled separately via a fixed -// catalog entry (Kind="enum" with EnumValues=[redis, memcached, -// valkey]) — see fields.go infra.cache.engine — because the engine -// matrix there is uniform across providers in v1. -// -// The provider-type string convention matches RegionCatalog: comes -// from the iac.provider module's `provider:` config field -// ("digitalocean", "aws", "gcp", "azure", "stub"). -type EngineCatalog struct { - byProviderType map[string][]string -} - -// NewEngineCatalog returns the v1 local engine catalog. Coverage -// reflects the typed drivers shipped by each cloud provider plugin -// as of 2026-05-27. AWS adds dynamodb + aurora atop the common -// postgres/mysql/mongodb/redis set per the design table. -func NewEngineCatalog() *EngineCatalog { - return &EngineCatalog{byProviderType: map[string][]string{ - "digitalocean": {"postgres", "mysql", "mongodb", "redis"}, - "aws": {"postgres", "mysql", "mongodb", "redis", "dynamodb", "aurora"}, - "gcp": {"postgres", "mysql", "redis", "spanner"}, - "azure": {"postgres", "mysql", "redis", "cosmos"}, - "stub": {"postgres"}, - }} -} - -// For returns a defensive copy of catalogued engines for the given -// provider type, or nil when uncatalogued. Defensive copy parallels -// RegionCatalog.For semantics. -func (e *EngineCatalog) For(providerType string) []string { - if e == nil { - return nil - } - src, ok := e.byProviderType[providerType] - if !ok { - return nil - } - out := make([]string, len(src)) - copy(out, src) - return out -} - -// Providers returns the sorted list of catalogued provider-type -// keys. Symmetric with RegionCatalog.Providers. -func (e *EngineCatalog) Providers() []string { - if e == nil { - return nil - } - out := make([]string, 0, len(e.byProviderType)) - for k := range e.byProviderType { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/iac/admin/catalog/engines_test.go b/iac/admin/catalog/engines_test.go deleted file mode 100644 index 53b06c5a..00000000 --- a/iac/admin/catalog/engines_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package catalog_test - -import ( - "slices" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" -) - -// TestEngineCatalog_NonEmptyPerProvider mirrors region catalog's -// non-empty invariant for database engines. -func TestEngineCatalog_NonEmptyPerProvider(t *testing.T) { - c := catalog.NewEngineCatalog() - for _, p := range c.Providers() { - engs := c.For(p) - if len(engs) == 0 { - t.Errorf("provider %q returned empty engine slice", p) - } - } -} - -// TestEngineCatalog_DefensiveCopy parallels RegionCatalog's slice- -// mutation guard. -func TestEngineCatalog_DefensiveCopy(t *testing.T) { - c := catalog.NewEngineCatalog() - a := c.For("aws") - if len(a) == 0 { - t.Fatal("aws returned no engines") - } - a[0] = "MUTATED" - b := c.For("aws") - if b[0] == "MUTATED" { - t.Errorf("catalog mutated via caller-side slice write: b[0]=%q", b[0]) - } -} - -// TestEngineCatalog_UncataloguedProviderReturnsNil mirrors region -// catalog's degradation contract. -func TestEngineCatalog_UncataloguedProviderReturnsNil(t *testing.T) { - c := catalog.NewEngineCatalog() - if got := c.For("nonexistent-cloud"); got != nil { - t.Errorf("For(unknown) = %v, want nil", got) - } -} - -// TestEngineCatalog_AWSSuperset asserts AWS is a strict superset of -// the common postgres/mysql/mongodb/redis set per the design's engine -// matrix (AWS additionally has dynamodb + aurora). -func TestEngineCatalog_AWSSuperset(t *testing.T) { - c := catalog.NewEngineCatalog() - aws := c.For("aws") - required := []string{"postgres", "mysql", "mongodb", "redis", "dynamodb", "aurora"} - for _, want := range required { - if !slices.Contains(aws, want) { - t.Errorf("aws missing required engine %q. got=%v", want, aws) - } - } -} - -// TestEngineCatalog_StubMinimal asserts the stub provider exposes -// the minimum engine surface for scenario tests — postgres only, -// per the design table. -func TestEngineCatalog_StubMinimal(t *testing.T) { - c := catalog.NewEngineCatalog() - stub := c.For("stub") - if len(stub) != 1 || stub[0] != "postgres" { - t.Errorf("stub engines = %v, want [postgres]", stub) - } -} - -// TestEngineCatalog_NilReceiver mirrors RegionCatalog nil-safety. -func TestEngineCatalog_NilReceiver(t *testing.T) { - var c *catalog.EngineCatalog - if got := c.For("aws"); got != nil { - t.Errorf("nil receiver For returned %v, want nil", got) - } - if got := c.Providers(); got != nil { - t.Errorf("nil receiver Providers returned %v, want nil", got) - } -} diff --git a/iac/admin/catalog/fields.go b/iac/admin/catalog/fields.go deleted file mode 100644 index 457b31c6..00000000 --- a/iac/admin/catalog/fields.go +++ /dev/null @@ -1,468 +0,0 @@ -// fields.go (T7b) populates the package-level catalogEntries and -// freeformReasons tables that T7a's New() / FreeformReason() expose. -// -// Per the T7a header comment (catalog.go), Go forbids redeclaring a -// package-level var across files in the same package, so this file -// reassigns the seams from init() rather than re-declaring them. -// -// Coverage maps the 13 typed `infra.*` Configs in -// workflow-plugin-infra/internal/contracts/infra.proto to their -// form-builder FieldSpecs. The catalog_proto_parity_test.go (T9) walks -// the vendored proto and asserts every non-allowlisted *Config message -// is covered here. -// -// Selectable-over-free-text contract: every Kind=="string" or -// Kind=="array_string" entry MUST carry a matching reason in -// freeformReasons[typeName][fieldName]. The audit test in -// fields_audit_test.go (T7b) enforces this. -// -// Design source: docs/plans/2026-05-27-infra-admin-dynamic-design.md -// §FieldSpec Catalog (lines ~410-445). - -package catalog - -func init() { - catalogEntries = func() map[string][]FieldSpec { - return map[string][]FieldSpec{ - // ---- infra.vpc → VPCConfig (proto §65) ---- - "infra.vpc": { - providerField(), - regionField(), - { - Name: "cidr", - Label: "CIDR block", - Kind: "string", // FREEFORM_OK: arbitrary RFC1918 / public-IP range - Required: true, - Description: "IPv4 CIDR (e.g. 10.0.0.0/16); per-provider validation runs on infra plan", - }, - { - Name: "availability_zones", - Label: "Availability zones", - Kind: "array_enum_dynamic", - EnumSource: "regions", - DependsOnField: "provider", - ElementKind: "enum_dynamic", - MinCount: 0, - MaxCount: 6, - Description: "AZ codes valid for the chosen provider+region", - }, - }, - - // ---- infra.container_service → ContainerServiceConfig (proto §17) ---- - "infra.container_service": { - providerField(), - regionField(), - { - Name: "image", - Label: "Container image", - Kind: "string", // FREEFORM_OK: registry tag (registry/path:tag) - Required: true, - Description: "Fully-qualified image tag, e.g. registry.example.com/svc:1.4.2", - }, - { - // MinCount/MaxCount on array_number FieldSpec applies as the - // per-element HTML5 min/max in new.js (NOT as array length — - // that's only seeded from MinCount). Range is the TCP/UDP - // port range, NOT a count of ports. Spec-reviewer F1. - Name: "ports", - Label: "Container ports", - Kind: "array_number", - ElementKind: "number", - MinCount: 1, - MaxCount: 65535, - Description: "TCP/UDP listen ports (1-65535)", - }, - { - Name: "replicas", - Label: "Replicas", - Kind: "number", - Required: true, - MinCount: 1, - MaxCount: 100, - DefaultValue: "1", - }, - }, - - // ---- infra.k8s_cluster → K8SClusterConfig (proto §29) ---- - "infra.k8s_cluster": { - providerField(), - regionField(), - { - Name: "version", - Label: "Kubernetes version", - Kind: "enum", - Required: true, - EnumValues: []string{"1.30", "1.29", "1.28"}, - Description: "Conservative shared version list. Switch to enum_dynamic + EnumSource=k8s-versions when per-provider variance matters.", - }, - { - Name: "node_count", - Label: "Node count", - Kind: "number", - Required: true, - MinCount: 1, - MaxCount: 1000, - DefaultValue: "3", - }, - { - Name: "node_size", - Label: "Node size", - Kind: "enum_dynamic", - EnumSource: "sizes", - Required: true, - DependsOnField: "", // sizes catalog is provider-independent in v1 - DefaultValue: "m", - }, - }, - - // ---- infra.database → DatabaseConfig (proto §40) ---- - "infra.database": { - providerField(), - regionField(), - { - Name: "engine", - Label: "Database engine", - Kind: "enum_dynamic", - EnumSource: "engines", - DependsOnField: "provider", - Required: true, - }, - { - Name: "version", - Label: "Engine version", - Kind: "string", // FREEFORM_OK: engine-specific (e.g. 15.5 for postgres) - Required: true, - Description: "Engine-specific version string (e.g. 15.5 for postgres)", - }, - { - Name: "size", - Label: "Instance size", - Kind: "enum", - Required: true, - EnumValues: []string{"xs", "s", "m", "l", "xl"}, - DefaultValue: "m", - }, - { - Name: "storage_gb", - Label: "Storage (GB)", - Kind: "number", - Required: true, - MinCount: 10, - MaxCount: 4096, - DefaultValue: "20", - }, - { - Name: "multi_az", - Label: "Multi-AZ", - Kind: "bool", - DefaultValue: "false", - }, - }, - - // ---- infra.cache → CacheConfig (proto §53) ---- - "infra.cache": { - providerField(), - regionField(), - { - Name: "engine", - Label: "Cache engine", - Kind: "enum", - Required: true, - EnumValues: []string{"redis", "memcached", "valkey"}, - DefaultValue: "redis", - }, - { - Name: "version", - Label: "Engine version", - Kind: "string", // FREEFORM_OK: engine-specific version - Required: true, - Description: "Engine-specific version string", - }, - { - Name: "size", - Label: "Instance size", - Kind: "enum", - Required: true, - EnumValues: []string{"xs", "s", "m", "l", "xl"}, - DefaultValue: "m", - }, - { - Name: "nodes", - Label: "Node count", - Kind: "number", - Required: true, - MinCount: 1, - MaxCount: 12, - DefaultValue: "1", - }, - }, - - // ---- infra.load_balancer → LoadBalancerConfig (proto §75) ---- - "infra.load_balancer": { - providerField(), - regionField(), - { - Name: "scheme", - Label: "Scheme", - Kind: "enum", - Required: true, - EnumValues: []string{"internet-facing", "internal"}, - DefaultValue: "internet-facing", - }, - { - // Same overload caveat as container_service.ports — range - // is the TCP port range, applied as per-element HTML5 - // min/max by new.js. Spec-reviewer F1. - Name: "ports", - Label: "Listener ports", - Kind: "array_number", - ElementKind: "number", - MinCount: 1, - MaxCount: 65535, - Description: "TCP listener ports (1-65535)", - }, - }, - - // ---- infra.dns → DNSConfig (proto §85) ---- - // - // Region is intentionally omitted: design §FieldSpec Catalog - // (line 427) lists DNSConfig fields as provider/zone/record/ - // target — DNS is a global-zone resource for most providers - // (Route53 global, DO DNS, CF DNS) and the proto's region - // field exists only because every InfraResourceConfig - // inherits region. Form-builder skips it to avoid prompting - // users for a value the driver ignores. Spec-reviewer F2. - "infra.dns": { - providerField(), - { - Name: "zone", - Label: "DNS zone", - Kind: "string", // FREEFORM_OK: domain name (apex) - Required: true, - Description: "Apex domain (e.g. example.com)", - }, - { - Name: "record", - Label: "Record name", - Kind: "string", // FREEFORM_OK: subdomain label - Required: true, - Description: "Subdomain label within the zone (e.g. api). rrtype is implicit per provider driver.", - }, - { - Name: "target", - Label: "Target", - Kind: "string", // FREEFORM_OK: IP address or domain - Required: true, - Description: "Record target — IPv4/IPv6 address or fully-qualified domain", - }, - }, - - // ---- infra.registry → RegistryConfig (proto §96) ---- - "infra.registry": { - providerField(), - regionField(), - { - Name: "name", - Label: "Registry name", - Kind: "string", // FREEFORM_OK: opaque provider-namespaced label - Required: true, - Description: "Provider-namespaced registry name; uniqueness scoped to the account", - }, - { - Name: "public", - Label: "Public registry", - Kind: "bool", - DefaultValue: "false", - }, - }, - - // ---- infra.api_gateway → APIGatewayConfig (proto §106) ---- - "infra.api_gateway": { - providerField(), - regionField(), - { - Name: "protocol", - Label: "Protocol", - Kind: "enum", - Required: true, - EnumValues: []string{"http", "https", "grpc", "websocket"}, - DefaultValue: "https", - }, - { - Name: "routes", - Label: "Routes", - Kind: "array_string", // FREEFORM_OK: opaque per-provider routing DSL - ElementKind: "string", - MinCount: 1, - MaxCount: 100, - Description: "Route specs in the provider's routing DSL (e.g. `/api/* → backend:8080`)", - }, - }, - - // ---- infra.firewall → FirewallConfig (proto §116) ---- - "infra.firewall": { - providerField(), - regionField(), - { - Name: "ingress", - Label: "Ingress rules", - Kind: "array_string", // FREEFORM_OK: per-provider rule DSL - ElementKind: "string", - MinCount: 0, - MaxCount: 100, - Description: "Ingress rule DSL (e.g. `tcp:443:0.0.0.0/0`)", - }, - { - Name: "egress", - Label: "Egress rules", - Kind: "array_string", // FREEFORM_OK: per-provider rule DSL - ElementKind: "string", - MinCount: 0, - MaxCount: 100, - Description: "Egress rule DSL (e.g. `tcp:0.0.0.0/0:443`)", - }, - }, - - // ---- infra.iam_role → IAMRoleConfig (proto §126) ---- - "infra.iam_role": { - providerField(), - regionField(), - { - Name: "name", - Label: "Role name", - Kind: "string", // FREEFORM_OK: provider-namespaced role label - Required: true, - Description: "Provider-namespaced role identifier", - }, - { - Name: "policies", - Label: "Attached policies", - Kind: "array_string", // FREEFORM_OK: opaque policy ARNs/IDs - ElementKind: "string", - MinCount: 0, - MaxCount: 50, - Description: "Policy ARNs (AWS) / role IDs (GCP) / built-in role names (Azure)", - }, - }, - - // ---- infra.storage → StorageConfig (proto §136) ---- - "infra.storage": { - providerField(), - regionField(), - { - Name: "name", - Label: "Bucket name", - Kind: "string", // FREEFORM_OK: globally-unique-per-provider bucket name - Required: true, - Description: "Provider-unique bucket / container name", - }, - { - Name: "class", - Label: "Storage class", - Kind: "enum", - Required: true, - EnumValues: []string{"standard", "cold", "archive", "nearline", "coldline"}, - DefaultValue: "standard", - }, - { - Name: "versioning", - Label: "Versioning enabled", - Kind: "bool", - DefaultValue: "false", - }, - }, - - // ---- infra.certificate → CertificateConfig (proto §147) ---- - "infra.certificate": { - providerField(), - regionField(), - { - Name: "domain", - Label: "Primary domain", - Kind: "string", // FREEFORM_OK: fully-qualified domain name - Required: true, - Description: "Primary FQDN (e.g. example.com)", - }, - { - Name: "subject_alt_names", - Label: "Subject alternative names", - Kind: "array_string", // FREEFORM_OK: SANs are arbitrary FQDNs - ElementKind: "string", - MinCount: 0, - MaxCount: 100, - Description: "Additional FQDNs covered by this certificate", - }, - }, - } - } - - freeformReasons = map[string]map[string]string{ - "infra.vpc": { - "cidr": "arbitrary RFC1918 / public-IP range; per-provider validation runs on infra plan", - }, - "infra.container_service": { - "image": "container registry tag — opaque registry/path:tag", - }, - "infra.database": { - "version": "engine-specific version string (postgres 15.5, mysql 8.0, etc.)", - }, - "infra.cache": { - "version": "engine-specific version string (redis 7.2, memcached 1.6.x, etc.)", - }, - "infra.dns": { - "zone": "apex domain — arbitrary FQDN", - "record": "subdomain label within the zone", - "target": "IPv4/IPv6 address or FQDN", - }, - "infra.registry": { - "name": "provider-namespaced registry name (no fixed enumeration)", - }, - "infra.api_gateway": { - "routes": "per-provider routing DSL — opaque rule strings", - }, - "infra.firewall": { - "ingress": "per-provider rule DSL (e.g. tcp:443:0.0.0.0/0)", - "egress": "per-provider rule DSL", - }, - "infra.iam_role": { - "name": "provider-namespaced role identifier", - "policies": "opaque policy identifiers (ARNs / role IDs)", - }, - "infra.storage": { - "name": "globally-unique-per-provider bucket / container name", - }, - "infra.certificate": { - "domain": "primary FQDN — arbitrary domain", - "subject_alt_names": "arbitrary FQDNs", - }, - } -} - -// providerField returns the common provider FieldSpec used by every -// typed `infra.*` Config. Defined as a helper so the catalog table -// stays compact and a single field shape (Name, EnumSource, etc.) -// applies consistently across all 13 entries. -func providerField() FieldSpec { - return FieldSpec{ - Name: "provider", - Label: "Provider module", - Kind: "enum_dynamic", - EnumSource: "providers", - Required: true, - Description: "Name of the iac.provider module (host YAML `name:` of the iac.provider entry)", - } -} - -// regionField is the common region FieldSpec — enum_dynamic with a -// per-provider region catalog, dependent on the `provider` field. -func regionField() FieldSpec { - return FieldSpec{ - Name: "region", - Label: "Region", - Kind: "enum_dynamic", - EnumSource: "regions", - DependsOnField: "provider", - Required: true, - Description: "Provider-specific region code (e.g. nyc1 for digitalocean, us-east-1 for aws)", - } -} diff --git a/iac/admin/catalog/fields_audit_test.go b/iac/admin/catalog/fields_audit_test.go deleted file mode 100644 index 215fc1f4..00000000 --- a/iac/admin/catalog/fields_audit_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package catalog_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" -) - -// TestCatalog_NoUnannotatedFreeText enforces the selectable-over-free-text -// contract from the design (§FieldSpec Catalog). Every catalog field with -// Kind "string" or "array_string" MUST carry a matching FREEFORM_OK reason -// in freeformReasons[typeName][fieldName]. -// -// New free-text fields added without a paired reason fail this test — -// the form-builder ergonomics depend on dropdowns being the default and -// text inputs being a deliberate exception with documented justification. -func TestCatalog_NoUnannotatedFreeText(t *testing.T) { - cat := catalog.New() - for _, typeName := range cat.AllTypes() { - fields, ok := cat.Get(typeName) - if !ok { - t.Fatalf("AllTypes returned %q but Get returned !ok", typeName) - } - for _, f := range fields { - if f.Kind != "string" && f.Kind != "array_string" { - continue - } - reason, hasReason := catalog.FreeformReason(typeName, f.Name) - if !hasReason || reason == "" { - t.Errorf("%s.%s is kind=%s but has no FREEFORM_OK reason in freeformReasons", - typeName, f.Name, f.Kind) - } - } - } -} - -// TestCatalog_AllExpectedTypesRegistered guards against accidental -// drop of one of the 13 typed Configs. The full proto-parity test -// (T9 catalog_proto_parity_test.go) walks the vendored proto and -// asserts the cross-product; this test is a fast unit-level -// canary that runs without filesystem dependencies. -func TestCatalog_AllExpectedTypesRegistered(t *testing.T) { - expected := []string{ - "infra.api_gateway", - "infra.cache", - "infra.certificate", - "infra.container_service", - "infra.database", - "infra.dns", - "infra.firewall", - "infra.iam_role", - "infra.k8s_cluster", - "infra.load_balancer", - "infra.registry", - "infra.storage", - "infra.vpc", - } - cat := catalog.New() - got := cat.AllTypes() - if len(got) != len(expected) { - t.Fatalf("AllTypes len=%d, want %d. got=%v", len(got), len(expected), got) - } - for i, name := range expected { - if got[i] != name { - t.Errorf("AllTypes[%d] = %q, want %q (full got=%v)", i, got[i], name, got) - } - } -} - -// regionOptionalTypes lists types where the catalog deliberately omits -// the universal region field because the underlying resource is -// region-less in the design table (DNS is global per most providers). -// See infra.dns header comment in fields.go (spec-reviewer F2). -var regionOptionalTypes = map[string]bool{ - "infra.dns": true, -} - -// TestCatalog_EveryTypeHasProviderAndRegion confirms the universal -// (provider, region) prefix is wired on every entry except those -// in regionOptionalTypes. The form-builder JS in new.js relies on -// the provider field being present everywhere (enum_dynamic source -// for dependent dropdowns); region presence is per-design. -func TestCatalog_EveryTypeHasProviderAndRegion(t *testing.T) { - cat := catalog.New() - for _, typeName := range cat.AllTypes() { - fields, _ := cat.Get(typeName) - var hasProvider, hasRegion bool - for _, f := range fields { - if f.Name == "provider" { - hasProvider = true - if f.Kind != "enum_dynamic" || f.EnumSource != "providers" { - t.Errorf("%s.provider: kind=%s enum_source=%s, want enum_dynamic/providers", - typeName, f.Kind, f.EnumSource) - } - } - if f.Name == "region" { - hasRegion = true - if f.DependsOnField != "provider" { - t.Errorf("%s.region: depends_on_field=%q, want \"provider\"", - typeName, f.DependsOnField) - } - } - } - if !hasProvider { - t.Errorf("%s: missing required `provider` field", typeName) - } - if !hasRegion && !regionOptionalTypes[typeName] { - t.Errorf("%s: missing `region` field (not in regionOptionalTypes allowlist)", typeName) - } - } -} - -// TestCatalog_EnumDynamicHasSource asserts every enum_dynamic field -// declares a non-empty EnumSource so the form-builder JS knows which -// resolver to invoke. An empty EnumSource produces an empty dropdown -// at render time, which is a footgun. -func TestCatalog_EnumDynamicHasSource(t *testing.T) { - cat := catalog.New() - for _, typeName := range cat.AllTypes() { - fields, _ := cat.Get(typeName) - for _, f := range fields { - if f.Kind == "enum_dynamic" || f.Kind == "array_enum_dynamic" { - if f.EnumSource == "" { - t.Errorf("%s.%s: kind=%s but EnumSource is empty", typeName, f.Name, f.Kind) - } - } - } - } -} diff --git a/iac/admin/catalog/naming.go b/iac/admin/catalog/naming.go deleted file mode 100644 index 772a9e0f..00000000 --- a/iac/admin/catalog/naming.go +++ /dev/null @@ -1,72 +0,0 @@ -package catalog - -import "strings" - -// ConfigProtoPackage is the fully-qualified proto package the vendored -// workflow-plugin-infra/internal/contracts/infra.proto declares. Used -// to build `config_message_fqn` references in AdminResourceTypeMetadata -// responses so cross-language consumers can correlate against the -// vendored proto descriptor. -// -// Note the **plural** "plugins" — earlier draft code (T6 commit -// 1ea231fdd) used the singular "plugin" which produced FQNs that -// nothing on the wire matched. Per spec-reviewer T6 F2 (commit -// 1ea231fdd). The vendored proto at iac/admin/testdata/infra.proto:8 -// is authoritative for this string. -const ConfigProtoPackage = "workflow.plugins.infra.v1" - -// ConfigMessageShortName maps an "infra." type name to its -// proto CamelCase Config message short name (e.g. "infra.vpc" → -// "VPCConfig"). Single-sourced here so the T9 vendored-proto parity -// test and the T6 handler library cannot drift on acronym -// preservation — earlier T6 code reimplemented snake→PascalCase -// without the acronym table and produced "VpcConfig", which doesn't -// exist in the proto. Per spec-reviewer T6 F2. -// -// Special-case acronym preservations (VPC, K8S, DNS, IAM, API) avoid -// degenerate `Vpc` ⇆ `VPC` toggling. The set is closed at 13 entries -// today (the design's typed-Config inventory); new acronyms in -// future Configs require both extending this switch AND updating -// the catalog. The vendored-proto parity test detects misses. -func ConfigMessageShortName(typeName string) string { - tail := strings.TrimPrefix(typeName, "infra.") - switch tail { - case "vpc": - return "VPCConfig" - case "k8s_cluster": - return "K8SClusterConfig" - case "dns": - return "DNSConfig" - case "iam_role": - return "IAMRoleConfig" - case "api_gateway": - return "APIGatewayConfig" - } - // Default: camelize snake-case tail (e.g. "container_service" → - // "ContainerService") then append "Config". Words are joined - // without separators per protobuf convention. - parts := strings.Split(tail, "_") - for i, p := range parts { - if len(p) == 0 { - continue - } - parts[i] = strings.ToUpper(p[:1]) + p[1:] - } - return strings.Join(parts, "") + "Config" -} - -// ConfigMessageFQN returns the fully-qualified proto message name for -// a given catalog type. Composition of ConfigProtoPackage + "." + -// ConfigMessageShortName so both halves can be tested independently -// and the FQN is always consistent between catalog handler usage -// and the vendored-proto parity test. -// -// Returns the empty string when typeName lacks the "infra." prefix — -// callers treat empty as "no FQN known" rather than emitting a -// malformed reference. -func ConfigMessageFQN(typeName string) string { - if !strings.HasPrefix(typeName, "infra.") { - return "" - } - return ConfigProtoPackage + "." + ConfigMessageShortName(typeName) -} diff --git a/iac/admin/catalog/regions.go b/iac/admin/catalog/regions.go deleted file mode 100644 index f53a77e2..00000000 --- a/iac/admin/catalog/regions.go +++ /dev/null @@ -1,88 +0,0 @@ -// regions.go (T8) hosts the per-provider region catalog used by the -// new-resource form-builder UI when populating the region dropdown for -// enum_dynamic+depends_on=provider fields. -// -// v1 is local-only — the design's IaCProviderRegionLister gRPC service -// extension is filed as a follow-up issue (per scope manifest §Out of -// scope). When a provider plugin lands runtime region listing, swap -// this constant table for the gRPC client. -// -// Refresh cadence: review on every minor upstream provider-plugin -// release. Add the new region codes here; the form-builder picks up -// the change after a host restart. -// -// Last reviewed: 2026-05-27. - -package catalog - -import "sort" - -// RegionCatalog maps provider-type strings (the YAML config -// `provider:` field on iac.provider modules) to their supported -// region codes. The provider-type string comes from -// workflow.plugins.infra.v1.InfraResourceConfig.provider — i.e. -// "digitalocean", "aws", "gcp", "azure", "stub" — NOT the host -// module name. -type RegionCatalog struct { - byProviderType map[string][]string -} - -// NewRegionCatalog returns the v1 local region catalog. Per design -// §FieldSpec Catalog the lists cover the regions surfaced by each -// provider plugin's typed driver as of 2026-05-27. Stub provider is -// included so unit + integration tests have deterministic options. -func NewRegionCatalog() *RegionCatalog { - return &RegionCatalog{byProviderType: map[string][]string{ - "digitalocean": { - "nyc1", "nyc3", "sfo3", "ams3", "sgp1", - "lon1", "fra1", "tor1", "blr1", "syd1", - }, - "aws": { - "us-east-1", "us-east-2", "us-west-1", "us-west-2", - "eu-west-1", "eu-central-1", - "ap-northeast-1", "ap-southeast-1", "ap-southeast-2", - }, - "gcp": { - "us-central1", "us-east1", "us-west1", - "europe-west1", "asia-east1", - }, - "azure": { - "eastus", "westus2", "westeurope", "southeastasia", - }, - "stub": { - "test-region-1", "test-region-2", - }, - }} -} - -// For returns a defensive copy of the catalogued regions for the -// given provider type, or nil when the provider is uncatalogued. -// The defensive copy prevents callers from mutating the catalog -// (the slices are otherwise shared across invocations). -func (r *RegionCatalog) For(providerType string) []string { - if r == nil { - return nil - } - src, ok := r.byProviderType[providerType] - if !ok { - return nil - } - out := make([]string, len(src)) - copy(out, src) - return out -} - -// Providers returns the sorted list of provider-type keys this -// catalog knows about. Useful for tests + diagnostics; not used by -// the form-builder which iterates AdminProviderSummary entries. -func (r *RegionCatalog) Providers() []string { - if r == nil { - return nil - } - out := make([]string, 0, len(r.byProviderType)) - for k := range r.byProviderType { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/iac/admin/catalog/regions_test.go b/iac/admin/catalog/regions_test.go deleted file mode 100644 index b751e004..00000000 --- a/iac/admin/catalog/regions_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package catalog_test - -import ( - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" -) - -// TestRegionCatalog_NonEmptyPerProvider pins the v1 region coverage -// contract: every catalogued provider returns at least one region. -// An empty list would render an empty region dropdown — a footgun -// for the new-resource form-builder. -func TestRegionCatalog_NonEmptyPerProvider(t *testing.T) { - c := catalog.NewRegionCatalog() - for _, p := range c.Providers() { - regs := c.For(p) - if len(regs) == 0 { - t.Errorf("provider %q returned empty region slice", p) - } - } -} - -// TestRegionCatalog_DefensiveCopy verifies callers cannot mutate the -// catalog by writing into the returned slice. The form-builder / -// handler library both receive the slice; an accidental sort or -// append-and-truncate elsewhere would otherwise corrupt subsequent -// invocations. -func TestRegionCatalog_DefensiveCopy(t *testing.T) { - c := catalog.NewRegionCatalog() - a := c.For("digitalocean") - if len(a) == 0 { - t.Fatal("digitalocean returned no regions") - } - a[0] = "MUTATED" - b := c.For("digitalocean") - if b[0] == "MUTATED" { - t.Errorf("catalog mutated via caller-side slice write: b[0]=%q", b[0]) - } -} - -// TestRegionCatalog_UncataloguedProviderReturnsNil pins the contract -// for unknown provider types — handler library degrades gracefully -// by falling back to free-text region input per design's -// populateProviderTypes degradation path. -func TestRegionCatalog_UncataloguedProviderReturnsNil(t *testing.T) { - c := catalog.NewRegionCatalog() - if got := c.For("nonexistent-cloud"); got != nil { - t.Errorf("For(unknown) = %v, want nil", got) - } -} - -// TestRegionCatalog_DigitalOceanSet asserts the design's documented -// DO region set is present verbatim — guards against accidental -// drop / typo when refreshing. -func TestRegionCatalog_DigitalOceanSet(t *testing.T) { - c := catalog.NewRegionCatalog() - got := c.For("digitalocean") - expected := map[string]bool{ - "nyc1": true, "nyc3": true, "sfo3": true, "ams3": true, - "sgp1": true, "lon1": true, "fra1": true, "tor1": true, - "blr1": true, "syd1": true, - } - if len(got) != len(expected) { - t.Errorf("DO regions len=%d, want %d. got=%v", len(got), len(expected), got) - } - for _, r := range got { - if !expected[r] { - t.Errorf("unexpected DO region %q", r) - } - } -} - -// TestRegionCatalog_NilReceiver guards against nil-pointer panics -// when callers pass a zero-value catalog (e.g. degradation mode). -func TestRegionCatalog_NilReceiver(t *testing.T) { - var c *catalog.RegionCatalog - if got := c.For("digitalocean"); got != nil { - t.Errorf("nil receiver For returned %v, want nil", got) - } - if got := c.Providers(); got != nil { - t.Errorf("nil receiver Providers returned %v, want nil", got) - } -} diff --git a/iac/admin/handler/apply_resource.go b/iac/admin/handler/apply_resource.go deleted file mode 100644 index 0fadf1d2..00000000 --- a/iac/admin/handler/apply_resource.go +++ /dev/null @@ -1,278 +0,0 @@ -package handler - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/GoCodeAlone/workflow/config" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// Enforcer is the server-side RBAC interface that the authz.casbin module -// implements. The variadic extra ...string matches the concrete Casbin -// wrapper's method signature (plan-review C-NEW-1). See module.Enforcer. -type Enforcer interface { - Enforce(sub, obj, act string, extra ...string) (bool, error) -} - -// ApplyResource implements the ApplyResource RPC. -// -// Security gates (in order): -// 1. authzError: default-deny if evidence is missing or unchecked. -// 2. Server-side Enforcer: if authz is non-nil, call -// Enforce(subject,"infra:apply","allow") — the client-body -// evidence is NOT trusted for RBAC; it is audit-only. -// 3. TOCTOU: re-plan and recompute desired_hash; reject if it -// diverges from in.DesiredHash. This prevents a stale plan from -// being applied after config changes. -// 4. ValidateAllowReplaceProtected: reject replace/delete on -// resources marked protected: true unless in.AllowReplace lists them. -// -// The providers map is keyed by module name; the first entry is used -// (single-provider model for v1.1). When no providers are registered, -// Output.error is set. -func ApplyResource( - ctx context.Context, - store interfaces.IaCStateStore, //nolint:revive // nil ok for no-state deploys - providers map[string]interfaces.IaCProvider, - authz Enforcer, - subject string, - cfg *config.WorkflowConfig, //nolint:revive // reserved for hash parity - desiredSpecs []interfaces.ResourceSpec, - in *adminpb.AdminApplyInput, -) (*adminpb.AdminApplyOutput, error) { - // Gate 1: default-deny. - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminApplyOutput{Error: msg}, ErrAuthzDenied - } - - // Gate 2: server-side RBAC (NOT the client's evidence.granted_permissions). - if authz != nil { - ok, enforceErr := authz.Enforce(subject, "infra:apply", "allow") - if enforceErr != nil { - return &adminpb.AdminApplyOutput{Error: "apply: authz enforce error"}, nil //nolint:nilerr - } - if !ok { - // Generic denial — do NOT reflect the authenticated subject in the - // response body. Subject is captured by the module-layer audit log. - return &adminpb.AdminApplyOutput{Error: "apply: infra:apply denied"}, ErrAuthzDenied - } - } - - if len(providers) == 0 { - return &adminpb.AdminApplyOutput{Error: "apply: no iac.provider registered"}, nil - } - - // Select the first provider. - var prov interfaces.IaCProvider - for _, p := range providers { - prov = p - break - } - - // Load current state. - var current []interfaces.ResourceState - if store != nil { - var err error - current, err = store.ListResources(ctx) - if err != nil { - return &adminpb.AdminApplyOutput{Error: "apply: list state: " + err.Error()}, nil //nolint:nilerr - } - } - - // Gate 3: TOCTOU — recompute hash and compare. - currentHash := handlerDesiredHash(cfg, desiredSpecs, current) - if currentHash != in.GetDesiredHash() { - return &adminpb.AdminApplyOutput{Error: "apply: plan is stale (desired_hash mismatch)"}, nil - } - - // Compute the plan (same as PlanResource to get the full action set). - filtered := filterPlanSpecs(desiredSpecs, in.GetAppContext(), "") - plan, err := prov.Plan(ctx, filtered, current) - if err != nil { - return &adminpb.AdminApplyOutput{Error: "apply: plan: " + err.Error()}, nil //nolint:nilerr - } - if plan == nil { - plan = &interfaces.IaCPlan{} - } - - // Gate 4: replace-protected validation. - allowSet := make(map[string]struct{}, len(in.GetAllowReplace())) - for _, n := range in.GetAllowReplace() { - allowSet[n] = struct{}{} - } - if err := handlerValidateAllowReplaceProtected(*plan, allowSet); err != nil { - return &adminpb.AdminApplyOutput{Error: "apply: " + err.Error()}, nil //nolint:nilerr - } - - // Execute the plan via the provider's ResourceDriver. - // Pass store so successful create/update actions are persisted to state. - result, applyErr := handlerApplyPlan(ctx, prov, plan, store) - if applyErr != nil { - return &adminpb.AdminApplyOutput{Error: "apply: " + applyErr.Error()}, nil //nolint:nilerr - } - - // Map apply result to proto. - out := &adminpb.AdminApplyOutput{} - for i := range result.Resources { - r := &result.Resources[i] - out.Applied = append(out.Applied, &adminpb.AdminResourceSummary{ - Name: r.Name, - Type: r.Type, - Status: "active", - }) - } - for i := range result.Errors { - e := &result.Errors[i] - out.Errors = append(out.Errors, &adminpb.AdminActionError{ - Resource: e.Resource, - Action: e.Action, - Error: redactCredentials(e.Error), - }) - } - return out, nil -} - -// handlerApplyPlan is a simplified apply loop that calls -// provider.ResourceDriver + driver.Create/Update/Delete per action. -// When store is non-nil, successful create/update actions persist the -// resulting ResourceState to the state store (assertion (1) from T10 spec). -// Provider errors are collected in result.Errors (best-effort, no early-return). -func handlerApplyPlan(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan, store interfaces.IaCStateStore) (*interfaces.ApplyResult, error) { - result := &interfaces.ApplyResult{} - for i := range plan.Actions { - a := &plan.Actions[i] - drv, err := p.ResourceDriver(a.Resource.Type) - if err != nil { - result.Errors = append(result.Errors, interfaces.ActionError{ - Resource: a.Resource.Name, - Action: a.Action, - Error: fmt.Sprintf("resolve driver: %s", err.Error()), - }) - continue - } - if drv == nil { - result.Errors = append(result.Errors, interfaces.ActionError{ - Resource: a.Resource.Name, - Action: a.Action, - Error: "no resource driver for type " + a.Resource.Type, - }) - continue - } - - switch a.Action { - case "create": - out, cerr := drv.Create(ctx, a.Resource) - switch { - case cerr != nil: - result.Errors = append(result.Errors, interfaces.ActionError{Resource: a.Resource.Name, Action: a.Action, Error: cerr.Error()}) - case out != nil: - result.Resources = append(result.Resources, interfaces.ResourceOutput{Name: out.Name, Type: out.Type, ProviderID: out.ProviderID}) - persistState(ctx, store, a.Resource, out.ProviderID) - default: - result.Resources = append(result.Resources, interfaces.ResourceOutput{Name: a.Resource.Name, Type: a.Resource.Type}) - persistState(ctx, store, a.Resource, "") - } - case "update": - ref := interfaces.ResourceRef{Name: a.Resource.Name, Type: a.Resource.Type} - if a.Current != nil { - ref.ProviderID = a.Current.ProviderID - } - out, uerr := drv.Update(ctx, ref, a.Resource) - switch { - case uerr != nil: - result.Errors = append(result.Errors, interfaces.ActionError{Resource: a.Resource.Name, Action: a.Action, Error: uerr.Error()}) - case out != nil: - result.Resources = append(result.Resources, interfaces.ResourceOutput{Name: out.Name, Type: out.Type, ProviderID: out.ProviderID}) - persistState(ctx, store, a.Resource, out.ProviderID) - default: - result.Resources = append(result.Resources, interfaces.ResourceOutput{Name: a.Resource.Name, Type: a.Resource.Type}) - persistState(ctx, store, a.Resource, ref.ProviderID) - } - case "delete", "replace": - // For delete, the Current carries the ref. - ref := interfaces.ResourceRef{Name: a.Resource.Name, Type: a.Resource.Type} - if a.Current != nil { - ref.ProviderID = a.Current.ProviderID - } - if derr := drv.Delete(ctx, ref); derr != nil { - result.Errors = append(result.Errors, interfaces.ActionError{Resource: a.Resource.Name, Action: a.Action, Error: derr.Error()}) - } - } - } - return result, nil -} - -// persistState writes a ResourceState to the store after a successful -// create or update. Errors are silently discarded — the apply itself -// succeeded at the provider level; a state-write failure is surfaced -// on the next read (stale state) rather than rolling back the cloud op. -// nil store is a no-op (test-only / store-less deploys). -func persistState(ctx context.Context, store interfaces.IaCStateStore, spec interfaces.ResourceSpec, providerID string) { - if store == nil { - return - } - _ = store.SaveResource(ctx, interfaces.ResourceState{ - Name: spec.Name, - Type: spec.Type, - ProviderID: providerID, - AppliedConfig: spec.Config, - }) -} - -// handlerValidateAllowReplaceProtected inlines wfctlhelpers.ValidateAllowReplaceProtected -// to avoid the iac/admin/handler → wfctlhelpers → module → iac/admin/handler import cycle. -func handlerValidateAllowReplaceProtected(plan interfaces.IaCPlan, allow map[string]struct{}) error { - type blocker struct{ name, action string } - var blockers []blocker - for i := range plan.Actions { - a := &plan.Actions[i] - if a.Action != "replace" && a.Action != "delete" { - continue - } - protected := false - if a.Resource.Config != nil { - if p, ok := a.Resource.Config["protected"].(bool); ok && p { - protected = true - } - } - if !protected && a.Current != nil && a.Current.AppliedConfig != nil { - if p, ok := a.Current.AppliedConfig["protected"].(bool); ok && p { - protected = true - } - } - if !protected { - continue - } - if _, ok := allow[a.Resource.Name]; ok { - continue - } - blockers = append(blockers, blocker{name: a.Resource.Name, action: a.Action}) - } - if len(blockers) == 0 { - return nil - } - var b strings.Builder - fmt.Fprintf(&b, "plan would require destructive action on %d protected resource(s):", len(blockers)) - names := make([]string, 0, len(blockers)) - for _, blk := range blockers { - fmt.Fprintf(&b, "\n %s (%s)", blk.name, blk.action) - names = append(names, blk.name) - } - fmt.Fprintf(&b, "\nto authorize, re-run with:\n --allow-replace=%s", strings.Join(names, ",")) - return errors.New(b.String()) -} - -// redactCredentials is a minimal guard that replaces DSN-style patterns -// (userinfo@ in URLs) to prevent credential leakage via error messages -// routed through Output.error. Not exhaustive — see the caveat in authz.go. -func redactCredentials(msg string) string { - // Simple heuristic: replace anything that looks like user:pass@host. - if !strings.Contains(msg, "@") || !strings.Contains(msg, "://") { - return msg - } - return "(provider error redacted — may contain credentials)" -} diff --git a/iac/admin/handler/apply_resource_test.go b/iac/admin/handler/apply_resource_test.go deleted file mode 100644 index 6e2debd1..00000000 --- a/iac/admin/handler/apply_resource_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package handler_test - -import ( - "context" - "errors" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// stubEnforcer is a minimal handler.Enforcer for apply tests. -type testEnforcer struct { - allow map[string]bool -} - -func (e *testEnforcer) Enforce(sub, obj, act string, _ ...string) (bool, error) { - if e.allow == nil { - return true, nil // default: allow all - } - return e.allow[sub+":"+obj], nil -} - -// TestApplyResource_DefaultDeny asserts that evidence with checked=false -// returns a non-empty error (default-deny). -func TestApplyResource_DefaultDeny(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc"}, - } - // Get a valid plan first. - planOut, _ := handler.PlanResource(context.Background(), nil, providers, nil, desired, - &adminpb.AdminPlanInput{Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}}) - - in := &adminpb.AdminApplyInput{ - PlanId: planOut.PlanId, - DesiredHash: planOut.DesiredHash, - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false}, - } - out, err := handler.ApplyResource(context.Background(), nil, providers, nil, "subject", nil, desired, in) - if !errors.Is(err, handler.ErrAuthzDenied) { - t.Fatalf("ApplyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError()) - } - if out.Error == "" { - t.Error("ApplyResource with evidence.checked=false should return non-empty error") - } -} - -// TestApplyResource_AuthzDenies asserts that a subject the enforcer -// denies infra:apply → 403 even if the client body has valid evidence. -func TestApplyResource_AuthzDenies(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{{Name: "vpc1", Type: "infra.vpc"}} - - planOut, _ := handler.PlanResource(context.Background(), nil, providers, nil, desired, - &adminpb.AdminPlanInput{Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}}) - - enforcer := &testEnforcer{allow: map[string]bool{ - // "viewer" is NOT granted infra:apply - }} - in := &adminpb.AdminApplyInput{ - PlanId: planOut.PlanId, - DesiredHash: planOut.DesiredHash, - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - // client claims granted_permissions:infra:apply — IGNORED by server - }, - } - out, err := handler.ApplyResource(context.Background(), nil, providers, enforcer, "viewer", nil, desired, in) - if !errors.Is(err, handler.ErrAuthzDenied) { - t.Fatalf("ApplyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError()) - } - if out.Error == "" { - t.Error("ApplyResource should reject subject denied infra:apply by server-side Enforcer") - } -} - -// TestApplyResource_HappyPath asserts that a valid evidence + hash + allowed -// subject returns applied[] with no errors. -func TestApplyResource_HappyPath(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, - } - - planOut, err := handler.PlanResource(context.Background(), nil, providers, nil, desired, - &adminpb.AdminPlanInput{Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}}) - if err != nil || planOut.Error != "" { - t.Fatalf("PlanResource: %v / %s", err, planOut.Error) - } - - in := &adminpb.AdminApplyInput{ - PlanId: planOut.PlanId, - DesiredHash: planOut.DesiredHash, - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - out, err := handler.ApplyResource(context.Background(), nil, providers, nil, "operator", nil, desired, in) - if err != nil { - t.Fatalf("ApplyResource: unexpected Go error: %v", err) - } - if out.Error != "" { - t.Fatalf("ApplyResource: output error: %s", out.Error) - } - if len(out.Errors) != 0 { - t.Errorf("ApplyResource: expected no per-resource errors, got: %v", out.Errors) - } -} - -// TestApplyResource_StalePlanHash asserts that a mismatched desired_hash -// → "plan is stale" error and no apply. -func TestApplyResource_StalePlanHash(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{{Name: "vpc1", Type: "infra.vpc"}} - - planOut, _ := handler.PlanResource(context.Background(), nil, providers, nil, desired, - &adminpb.AdminPlanInput{Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}}) - - in := &adminpb.AdminApplyInput{ - PlanId: planOut.PlanId, - DesiredHash: "stale-hash-does-not-match", - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - out, err := handler.ApplyResource(context.Background(), nil, providers, nil, "operator", nil, desired, in) - if err != nil { - t.Fatalf("ApplyResource: unexpected Go error: %v", err) - } - if out.Error == "" { - t.Error("ApplyResource with stale hash should return non-empty error") - } - if out.Error != "apply: plan is stale (desired_hash mismatch)" { - t.Errorf("stale hash error = %q, want exact literal", out.Error) - } -} - -// TestApplyResource_ReplaceWithoutAuthorization asserts Gate 4: -// a plan containing a replace action on a protected:true resource with an -// empty allow_replace list must be rejected before any cloud operation -// (IMPORTANT-2 fix). -func TestApplyResource_ReplaceWithoutAuthorization(t *testing.T) { - // Build a stub provider that returns a plan with a replace action on a - // protected resource — we need a provider whose Plan method returns a - // pre-built plan rather than using the stub's dynamic one. - prov := &replacePlanProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - - // desiredSpecs must match the provider's plan output so the hash lines up. - desired := []interfaces.ResourceSpec{ - {Name: "protected-db", Type: "infra.database", Config: map[string]any{"protected": true, "size": "xl"}}, - } - - // Get the desired_hash for these specs with no current state. - planIn := &adminpb.AdminPlanInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - planOut, err := handler.PlanResource(context.Background(), nil, providers, nil, desired, planIn) - if err != nil || planOut.Error != "" { - t.Fatalf("PlanResource setup: %v / %s", err, planOut.Error) - } - - in := &adminpb.AdminApplyInput{ - PlanId: planOut.PlanId, - DesiredHash: planOut.DesiredHash, - AllowReplace: nil, // explicitly empty — no replace authorization - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - out, err := handler.ApplyResource(context.Background(), nil, providers, nil, "operator", nil, desired, in) - if err != nil { - t.Fatalf("ApplyResource: unexpected Go error: %v", err) - } - if out.Error == "" { - t.Error("ApplyResource should reject replace on protected resource with empty allow_replace") - } - if len(out.Applied) > 0 { - t.Error("ApplyResource: no resources should be applied when replace is unauthorized") - } -} - -// replacePlanProvider is a stub provider that always returns a single -// replace action on a protected resource, used to test Gate 4. -type replacePlanProvider struct{} - -var _ interfaces.IaCProvider = (*replacePlanProvider)(nil) - -func (p *replacePlanProvider) Name() string { return "replace-stub" } -func (p *replacePlanProvider) Version() string { return "0.1.0" } -func (p *replacePlanProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } -func (p *replacePlanProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } -func (p *replacePlanProvider) Plan(_ context.Context, desired []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { - // Always return a replace action on the first spec, marked as protected. - if len(desired) == 0 { - return &interfaces.IaCPlan{}, nil - } - spec := desired[0] - if spec.Config == nil { - spec.Config = map[string]any{} - } - spec.Config["protected"] = true - return &interfaces.IaCPlan{ - Actions: []interfaces.PlanAction{ - {Action: "replace", Resource: spec}, - }, - }, nil -} -func (p *replacePlanProvider) Destroy(_ context.Context, refs []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { - names := make([]string, 0, len(refs)) - for _, r := range refs { - names = append(names, r.Name) - } - return &interfaces.DestroyResult{Destroyed: names}, nil -} -func (p *replacePlanProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { - return nil, nil -} -func (p *replacePlanProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { - return nil, nil -} -func (p *replacePlanProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { - return nil, nil -} -func (p *replacePlanProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { - return nil, nil -} -func (p *replacePlanProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { - return nil, nil -} -func (p *replacePlanProvider) SupportedCanonicalKeys() []string { return nil } -func (p *replacePlanProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { - return nil, nil -} -func (p *replacePlanProvider) Close() error { return nil } diff --git a/iac/admin/handler/authz.go b/iac/admin/handler/authz.go deleted file mode 100644 index 8c39e91a..00000000 --- a/iac/admin/handler/authz.go +++ /dev/null @@ -1,76 +0,0 @@ -// Package handler hosts the infra.admin handler library — the shared -// business logic dispatched by both the host-side infra.admin -// workflow module's HTTP routes (T15) and the wfctl `infra admin *` -// CLI subcommands (T19-T20). Functions are pure: they take their -// dependencies as parameters (state store, providers, catalog) and -// return typed adminpb outputs. The HTTP transport + audit logging -// happens at the module layer; the CLI transport happens at wfctl. -// -// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Handler library -// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Tasks 5 + 6) -// -// Authz contract (this file): every typed input MUST carry an -// AdminAuthzEvidence whose authz_checked AND authz_allowed are both -// true. The host module attaches admin-auth middleware on every -// registered route; the middleware sets the evidence after running -// authz.Casbin (or whatever the configured authz module is). If the -// evidence is missing or either bit is false, the handler refuses -// the request via the Output.error field (NOT a Go-level error, so -// HTTP transport returns 200 OK with a typed payload that consumers -// must inspect for non-empty error per the proto tag-100 discriminator). -// -// Default-deny semantics: handler refuses unless BOTH bits prove the -// host auth pipeline ran AND approved. A missing evidence means the -// caller bypassed admin auth middleware — likely a wiring bug — and -// must be refused for safety per design §Authz row. -package handler - -// **Error-string credential-leak caveat** (per code-reviewer T5 M-5, -// commit 5fe88fe45): every handler in this package returns upstream -// error messages through Output.error verbatim (e.g. -// "list state: " + err.Error()). Current upstream errors come from -// os.ReadFile / json.Marshal / fake stores in tests — none carry -// credentials. But future backends (e.g. a Postgres-backed state -// store that errors with a DSN-in-message) could leak secrets -// through this channel. Scrub well-known credential-bearing -// patterns (URLs with userinfo, etc.) at the backend boundary OR -// in this package before concatenating, OR pin the no-credential -// upstream assumption per backend at integration-test time (T17). -// Not in T5 scope; flagged here so a future contributor sees the -// risk before extending the handler family. - -import ( - "errors" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" -) - -// ErrAuthzDenied is the sentinel error returned by handlers when an -// authz check (evidence default-deny or server-side RBAC via Enforcer) -// rejects the request. The HTTP module layer maps this via errors.Is to -// HTTP 403 — NOT via strings.Contains on the error message, which would -// produce false positives when a provider's error message happens to -// contain "denied". -var ErrAuthzDenied = errors.New("authz denied") - -// authzError returns the operator-facing rejection string when the -// supplied evidence does not meet default-deny criteria. Returns "" -// when evidence is acceptable. Callers funnel the non-empty return -// into Output.error and short-circuit further work. -// -// Per design §Authz: read endpoints require -// authz_checked && authz_allowed. The "authz" substring in the -// message is load-bearing — operator-grep convention and pinned by -// TestListResources_DenyMessageMentionsAuthz. -func authzError(ev *adminpb.AdminAuthzEvidence) string { - if ev == nil { - return "authz evidence missing — admin middleware did not attach to this route (host wiring bug)" - } - if !ev.AuthzChecked { - return "authz check did not run — evidence.authz_checked=false (admin middleware bypassed or misconfigured)" - } - if !ev.AuthzAllowed { - return "authz denied — evidence.authz_allowed=false" - } - return "" -} diff --git a/iac/admin/handler/destroy_resource.go b/iac/admin/handler/destroy_resource.go deleted file mode 100644 index aa59c960..00000000 --- a/iac/admin/handler/destroy_resource.go +++ /dev/null @@ -1,126 +0,0 @@ -package handler - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "sort" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// DestroyResource implements the DestroyResource RPC. -// -// Security gates: -// 1. authzError: default-deny if evidence missing/unchecked. -// 2. Server-side Enforcer: Enforce(subject,"infra:destroy","allow"). -// 3. TOCTOU: confirm_hash must match a server-computed hash of the refs -// being destroyed. Clients compute this hash from the resource list -// they obtained from ListResources/GetResource and echo it here; -// a mismatch means the list changed between listing and destroying. -// -// The destroy path calls provider.Destroy directly (no plan step — -// the caller supplies refs from the UI's resource list, which already -// came from the state store). -func DestroyResource( - ctx context.Context, - providers map[string]interfaces.IaCProvider, - authz Enforcer, - subject string, - in *adminpb.AdminDestroyInput, -) (*adminpb.AdminDestroyOutput, error) { - // Gate 1: default-deny. - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminDestroyOutput{Error: msg}, ErrAuthzDenied - } - - // Gate 2: server-side RBAC. - if authz != nil { - ok, enforceErr := authz.Enforce(subject, "infra:destroy", "allow") - if enforceErr != nil { - return &adminpb.AdminDestroyOutput{Error: "destroy: authz enforce error"}, nil //nolint:nilerr - } - if !ok { - // Generic denial — do NOT reflect the authenticated subject in the - // response body. Subject is captured by the module-layer audit log. - return &adminpb.AdminDestroyOutput{Error: "destroy: infra:destroy denied"}, ErrAuthzDenied - } - } - - // Gate 3: TOCTOU — confirm_hash must match server-computed hash of the refs. - // An empty confirm_hash means the client skipped the TOCTOU step; reject. - expectedHash := hashDestroyRefs(in.GetRefs()) - if in.GetConfirmHash() != expectedHash { - return &adminpb.AdminDestroyOutput{Error: "destroy: confirm_hash mismatch — resource list has changed since this destroy was initiated"}, nil - } - - if len(providers) == 0 { - return &adminpb.AdminDestroyOutput{Error: "destroy: no iac.provider registered"}, nil - } - - // Select the first provider. - var prov interfaces.IaCProvider - for _, p := range providers { - prov = p - break - } - - // Convert proto refs to interfaces.ResourceRef. - refs := make([]interfaces.ResourceRef, 0, len(in.GetRefs())) - for _, r := range in.GetRefs() { - refs = append(refs, interfaces.ResourceRef{ - Name: r.GetName(), - Type: r.GetType(), - }) - } - - result, err := prov.Destroy(ctx, refs) - if err != nil { - return &adminpb.AdminDestroyOutput{Error: "destroy: " + redactCredentials(err.Error())}, nil //nolint:nilerr - } - if result == nil { - return &adminpb.AdminDestroyOutput{}, nil - } - - out := &adminpb.AdminDestroyOutput{ - Destroyed: result.Destroyed, - } - for i := range result.Errors { - e := &result.Errors[i] - out.Errors = append(out.Errors, &adminpb.AdminActionError{ - Resource: e.Resource, - Action: e.Action, - Error: redactCredentials(e.Error), - }) - } - return out, nil -} - -// HashDestroyRefs computes a deterministic SHA-256 hex digest of the refs -// being destroyed. Exported so UI and tests can compute the expected hash -// before calling DestroyResource. The hash is over the refs sorted by Name, -// serialised as JSON [{name,type},...]. -func HashDestroyRefs(refs []*adminpb.AdminResourceRef) string { - return hashDestroyRefs(refs) -} - -// hashDestroyRefs is the internal implementation. Sorted so order of refs -// in the request body doesn't affect the hash. -func hashDestroyRefs(refs []*adminpb.AdminResourceRef) string { - type refKey struct{ Name, Type string } - sorted := make([]refKey, 0, len(refs)) - for _, r := range refs { - sorted = append(sorted, refKey{r.GetName(), r.GetType()}) - } - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) - data, err := json.Marshal(sorted) - if err != nil { - // Use a non-matchable sentinel so an empty client confirm_hash - // never accidentally satisfies the gate on a marshal failure. - return fmt.Sprintf("hash-error-%d-refs", len(refs)) - } - sum := sha256.Sum256(data) - return fmt.Sprintf("%x", sum) -} diff --git a/iac/admin/handler/destroy_resource_test.go b/iac/admin/handler/destroy_resource_test.go deleted file mode 100644 index cedb936b..00000000 --- a/iac/admin/handler/destroy_resource_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package handler_test - -import ( - "context" - "errors" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// TestDestroyResource_DefaultDeny asserts that evidence with checked=false -// returns a non-empty error (default-deny). -func TestDestroyResource_DefaultDeny(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - in := &adminpb.AdminDestroyInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false}, - Refs: []*adminpb.AdminResourceRef{ - {Name: "vpc1", Type: "infra.vpc"}, - }, - } - out, err := handler.DestroyResource(context.Background(), providers, nil, "operator", in) - if !errors.Is(err, handler.ErrAuthzDenied) { - t.Fatalf("DestroyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError()) - } - if out.Error == "" { - t.Error("DestroyResource with evidence.checked=false should return non-empty error") - } -} - -// TestDestroyResource_AuthzDenies asserts that a subject denied -// infra:destroy by the Enforcer is rejected even with valid evidence. -func TestDestroyResource_AuthzDenies(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - enforcer := &testEnforcer{allow: map[string]bool{ - // viewer is NOT granted infra:destroy - }} - in := &adminpb.AdminDestroyInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - Refs: []*adminpb.AdminResourceRef{{Name: "vpc1", Type: "infra.vpc"}}, - } - out, err := handler.DestroyResource(context.Background(), providers, enforcer, "viewer", in) - if !errors.Is(err, handler.ErrAuthzDenied) { - t.Fatalf("DestroyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError()) - } - if out.Error == "" { - t.Error("DestroyResource should reject subject denied infra:destroy by server-side Enforcer") - } -} - -// TestDestroyResource_HappyPath asserts that a valid subject + refs + correct -// confirm_hash → destroyed[] with the ref names. -func TestDestroyResource_HappyPath(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - refs := []*adminpb.AdminResourceRef{ - {Name: "vpc1", Type: "infra.vpc"}, - {Name: "db1", Type: "infra.database"}, - } - in := &adminpb.AdminDestroyInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - Refs: refs, - ConfirmHash: handler.HashDestroyRefs(refs), // TOCTOU: echo server-computed hash - } - out, err := handler.DestroyResource(context.Background(), providers, nil, "operator", in) - if err != nil { - t.Fatalf("DestroyResource: unexpected Go error: %v", err) - } - if out.Error != "" { - t.Fatalf("DestroyResource: output error: %s", out.Error) - } - if len(out.Destroyed) != 2 { - t.Errorf("DestroyResource: expected 2 destroyed, got %d", len(out.Destroyed)) - } -} - -// TestDestroyResource_MismatchedConfirmHash asserts that a wrong or empty -// confirm_hash → TOCTOU error, no destroy operation performed (IMPORTANT-1). -func TestDestroyResource_MismatchedConfirmHash(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - refs := []*adminpb.AdminResourceRef{ - {Name: "vpc1", Type: "infra.vpc"}, - } - - // Empty confirm_hash — should be rejected. - in := &adminpb.AdminDestroyInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - Refs: refs, - ConfirmHash: "", // deliberately empty - } - out, err := handler.DestroyResource(context.Background(), providers, nil, "operator", in) - if err != nil { - t.Fatalf("DestroyResource: unexpected Go error: %v", err) - } - if out.Error == "" { - t.Error("DestroyResource with empty confirm_hash should return TOCTOU error") - } - - // Wrong confirm_hash — should also be rejected. - in2 := &adminpb.AdminDestroyInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - Refs: refs, - ConfirmHash: "wrong-hash-stale", - } - out2, err := handler.DestroyResource(context.Background(), providers, nil, "operator", in2) - if err != nil { - t.Fatalf("DestroyResource: unexpected Go error: %v", err) - } - if out2.Error == "" { - t.Error("DestroyResource with wrong confirm_hash should return TOCTOU error") - } - if len(out2.Destroyed) > 0 { - t.Error("DestroyResource: no resources should be destroyed when confirm_hash mismatches") - } -} diff --git a/iac/admin/handler/drift_check.go b/iac/admin/handler/drift_check.go deleted file mode 100644 index aca30cb4..00000000 --- a/iac/admin/handler/drift_check.go +++ /dev/null @@ -1,64 +0,0 @@ -package handler - -import ( - "context" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// DriftCheckResource implements the DriftCheckResource RPC: calls -// provider.DetectDrift on the supplied resource refs and returns -// per-resource drift results. -// -// The providers map is keyed by module name; the first entry is used -// (single-provider model for v1.1). When no providers are registered, -// Output.error is set so the client receives a typed diagnostic. -// -// Evidence default-deny: if authzError is non-empty, the handler -// short-circuits with Output.error (HTTP stays 200; consumer sniffs tag-100). -func DriftCheckResource( - ctx context.Context, - providers map[string]interfaces.IaCProvider, - in *adminpb.AdminDriftInput, -) (*adminpb.AdminDriftOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminDriftOutput{Error: msg}, nil - } - if len(providers) == 0 { - return &adminpb.AdminDriftOutput{Error: "drift: no iac.provider registered"}, nil - } - - // Select the first provider. - var prov interfaces.IaCProvider - for _, p := range providers { - prov = p - break - } - - // Convert proto refs → interfaces.ResourceRef. - refs := make([]interfaces.ResourceRef, 0, len(in.GetRefs())) - for _, r := range in.GetRefs() { - refs = append(refs, interfaces.ResourceRef{ - Name: r.GetName(), - Type: r.GetType(), - }) - } - - results, err := prov.DetectDrift(ctx, refs) - if err != nil { - return &adminpb.AdminDriftOutput{Error: "drift: " + err.Error()}, nil //nolint:nilerr - } - - drift := make([]*adminpb.AdminDriftResult, 0, len(results)) - for _, r := range results { - drift = append(drift, &adminpb.AdminDriftResult{ - ResourceName: r.Name, - Type: r.Type, - Drifted: r.Drifted, - Class: string(r.Class), - Fields: r.Fields, - }) - } - return &adminpb.AdminDriftOutput{Drift: drift}, nil -} diff --git a/iac/admin/handler/drift_check_test.go b/iac/admin/handler/drift_check_test.go deleted file mode 100644 index 0ea90eb6..00000000 --- a/iac/admin/handler/drift_check_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package handler_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// TestDriftCheckResource_DefaultDeny asserts that evidence with checked=false -// returns a non-empty error and no drift payload. -func TestDriftCheckResource_DefaultDeny(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - in := &adminpb.AdminDriftInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false}, - Refs: []*adminpb.AdminResourceRef{ - {Name: "vpc1", Type: "infra.vpc"}, - }, - } - out, err := handler.DriftCheckResource(context.Background(), providers, in) - if err != nil { - t.Fatalf("DriftCheckResource: unexpected error: %v", err) - } - if out.Error == "" { - t.Error("DriftCheckResource with evidence.checked=false should return non-empty error") - } - if len(out.Drift) > 0 { - t.Error("DriftCheckResource with denial should return no drift payload") - } -} - -// TestDriftCheckResource_ReturnsNotDrifted asserts that the stub provider's -// DetectDrift (Drifted:false) maps to AdminDriftResult with Drifted:false. -func TestDriftCheckResource_ReturnsNotDrifted(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - in := &adminpb.AdminDriftInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - Refs: []*adminpb.AdminResourceRef{ - {Name: "vpc1", Type: "infra.vpc"}, - {Name: "db1", Type: "infra.database"}, - }, - } - out, err := handler.DriftCheckResource(context.Background(), providers, in) - if err != nil { - t.Fatalf("DriftCheckResource: unexpected error: %v", err) - } - if out.Error != "" { - t.Fatalf("DriftCheckResource: unexpected output error: %s", out.Error) - } - if len(out.Drift) != 2 { - t.Fatalf("DriftCheckResource: expected 2 drift results, got %d", len(out.Drift)) - } - for _, r := range out.Drift { - if r.Drifted { - t.Errorf("DriftCheckResource: expected Drifted:false for %q, got true", r.ResourceName) - } - } -} - -// TestDriftCheckResource_NoProviderError asserts that calling DriftCheckResource -// with no providers returns an error via output.error. -func TestDriftCheckResource_NoProviderError(t *testing.T) { - in := &adminpb.AdminDriftInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - Refs: []*adminpb.AdminResourceRef{ - {Name: "vpc1", Type: "infra.vpc"}, - }, - } - out, err := handler.DriftCheckResource(context.Background(), nil, in) - if err != nil { - t.Fatalf("DriftCheckResource: unexpected Go error: %v", err) - } - if out.Error == "" { - t.Error("DriftCheckResource with no providers should return non-empty error") - } -} diff --git a/iac/admin/handler/generate_config.go b/iac/admin/handler/generate_config.go deleted file mode 100644 index 892dd3bd..00000000 --- a/iac/admin/handler/generate_config.go +++ /dev/null @@ -1,210 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "gopkg.in/yaml.v3" -) - -// moduleEntry is the YAML shape GenerateConfig emits — a single -// module entry under a host's `modules:` block. Field order is the -// canonical workflow config layout (name → type → config). Using a -// typed struct + yaml.Marshal is the strict-contract path the plan -// mandates: we never fmt.Sprintf user input into YAML, which would -// admit injection-of-arbitrary-keys via crafted field values. -type moduleEntry struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Config map[string]any `yaml:"config,omitempty"` -} - -// GenerateConfig implements InfraAdminService.GenerateConfig by -// type-coercing the form-builder's field_values map against the -// FieldSpecCatalog Kind dispatch, assembling a module config map, -// and yaml.Marshal-ing the result. Output is a single module entry -// (name + type + config) the user pastes under their existing -// `modules:` block. Per plan §Task 6. -// -// **Strict-contract invariant**: never fmt.Sprintf user input into -// YAML. All values flow through yaml.Marshal of a typed struct or -// map. TestGenerateConfig_NoFmtSprintfUserInput pins this against -// regression. -// -// Array encoding contract (cross-task, locked 2026-05-27): -// array_string + array_object field values arrive JSON-encoded -// (e.g. `field_values["ingress"] = "[\"rule a\", \"rule b, c\"]"`) -// so values containing commas survive the wire. Handler decodes -// via json.Unmarshal. Defensive fallback: a value that doesn't -// parse as a JSON array is wrapped into a one-element slice so a -// malformed UI submission doesn't crash the server. See -// TestGenerateConfig_ArrayValuesJSONDecoded + -// TestGenerateConfig_PlainStringNotJSONDecoded. -// -// Per design §Authz: default-deny via the shared authz guard. -func GenerateConfig( - ctx context.Context, - fieldCat *catalog.FieldSpecCatalog, - in *adminpb.AdminGenerateConfigInput, -) (*adminpb.AdminGenerateConfigOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminGenerateConfigOutput{Error: msg}, nil - } - - specs, ok := fieldCat.Get(in.GetResourceType()) - if !ok { - return &adminpb.AdminGenerateConfigOutput{ - ValidationErrors: []string{ - fmt.Sprintf("unknown resource_type %q — not in FieldSpec catalog", in.GetResourceType()), - }, - }, nil - } - - cfg := map[string]any{} - var validationErrors []string - for i := range specs { - spec := &specs[i] // gocritic rangeValCopy: avoid 176-byte copy per iteration - raw, present := in.GetFieldValues()[spec.Name] - if !present || raw == "" { - if spec.Required { - validationErrors = append(validationErrors, - fmt.Sprintf("missing required field %q", spec.Name)) - } - continue - } - coerced, verrs := coerceFieldValue(spec, raw) - if len(verrs) > 0 { - validationErrors = append(validationErrors, verrs...) - continue - } - cfg[spec.Name] = coerced - } - - entry := moduleEntry{ - Name: in.GetResourceName(), - Type: in.GetResourceType(), - Config: cfg, - } - yamlBytes, err := yaml.Marshal(entry) - if err != nil { - //nolint:nilerr // proto tag-100 convention; see list_resources.go for rationale - return &adminpb.AdminGenerateConfigOutput{ - Error: "marshal yaml: " + err.Error(), - ValidationErrors: validationErrors, - }, nil - } - - return &adminpb.AdminGenerateConfigOutput{ - YamlSnippet: string(yamlBytes), - ValidationErrors: validationErrors, - }, nil -} - -// coerceFieldValue parses the string-encoded form value into the -// catalog-declared Kind. Returns (coercedValue, nil) on success or -// (nil, []validationError) on parse failure. The handler accumulates -// validation errors and continues processing other fields so the -// UI can surface every problem in one round-trip. -func coerceFieldValue(spec *catalog.FieldSpec, raw string) (any, []string) { - switch spec.Kind { - case "string": - return raw, nil - case "enum", "enum_dynamic": - // Dropdowns submit a string value. Catalog cannot validate - // enum_dynamic values without the live providers/regions - // data; the host module can post-validate via T6 future - // enhancement. v1 accepts the string verbatim. - return raw, nil - case "bool": - v, err := strconv.ParseBool(raw) - if err != nil { - return nil, []string{fmt.Sprintf("field %q: invalid bool %q", spec.Name, raw)} - } - return v, nil - case "number": - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return nil, []string{fmt.Sprintf("field %q: invalid number %q", spec.Name, raw)} - } - // Bounds check: MaxCount/MinCount on number-kind fields carry - // the value range per design's "number-with-bounds" - // convention. - if spec.MinCount != 0 && v < int64(spec.MinCount) { - return nil, []string{fmt.Sprintf("field %q: %d below min %d", spec.Name, v, spec.MinCount)} - } - if spec.MaxCount != 0 && v > int64(spec.MaxCount) { - return nil, []string{fmt.Sprintf("field %q: %d above max %d", spec.Name, v, spec.MaxCount)} - } - return v, nil - case "array_string", "array_object", "array_enum_dynamic", "array_number": - return coerceArrayValue(spec, raw) - case "object": - // Object kind: expect JSON-encoded payload, decode to - // map[string]any so yaml.Marshal emits a nested map. - var m map[string]any - if err := json.Unmarshal([]byte(raw), &m); err != nil { - return nil, []string{fmt.Sprintf("field %q: invalid object JSON: %v", spec.Name, err)} - } - return m, nil - default: - // Unknown kind — accept verbatim with a validation warning so - // the catalog can introduce new kinds without immediately - // crashing in-flight requests. - return raw, []string{fmt.Sprintf("field %q: unrecognized kind %q (accepted verbatim)", spec.Name, spec.Kind)} - } -} - -// coerceArrayValue handles the cross-task contract for array-shaped -// field_values: input arrives as a JSON-encoded array string (the -// canonical form) OR a plain literal (defensive fallback). Returns -// a []any whose elements are coerced per spec.ElementKind so yaml. -// Marshal emits a proper YAML sequence. -func coerceArrayValue(spec *catalog.FieldSpec, raw string) (any, []string) { - trimmed := strings.TrimSpace(raw) - var elements []any - if strings.HasPrefix(trimmed, "[") { - // JSON-encoded array — canonical shape per cross-task contract. - var stringElems []string - if err := json.Unmarshal([]byte(trimmed), &stringElems); err == nil { - for _, s := range stringElems { - elements = append(elements, s) - } - } else { - // Try heterogeneous array (e.g. array_number). - if err := json.Unmarshal([]byte(trimmed), &elements); err != nil { - return nil, []string{fmt.Sprintf("field %q: invalid array JSON: %v", spec.Name, err)} - } - } - } else { - // Defensive fallback: plain literal becomes a one-element array. - // TestGenerateConfig_PlainStringNotJSONDecoded pins this shape. - elements = []any{raw} - } - // Element-kind coercion: array_number elements come back as - // float64 from json.Unmarshal — coerce to int64 so YAML emits - // integers rather than floats. - if spec.ElementKind == "number" { - coerced := make([]any, 0, len(elements)) - for _, e := range elements { - switch v := e.(type) { - case float64: - coerced = append(coerced, int64(v)) - case string: - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - coerced = append(coerced, n) - } else { - return nil, []string{fmt.Sprintf("field %q: invalid number element %q", spec.Name, v)} - } - default: - coerced = append(coerced, e) - } - } - elements = coerced - } - return elements, nil -} diff --git a/iac/admin/handler/generate_config_test.go b/iac/admin/handler/generate_config_test.go deleted file mode 100644 index 00fa1826..00000000 --- a/iac/admin/handler/generate_config_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package handler_test - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "gopkg.in/yaml.v3" -) - -// TestGenerateConfig_HappyPath_VPC verifies the typical form -// submission for an infra.vpc resource produces a well-formed -// module YAML snippet. The YAML round-trips through yaml.Unmarshal -// to a generic map so the test asserts shape semantically rather -// than via string-equality (whitespace + key ordering vary). -func TestGenerateConfig_HappyPath_VPC(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.vpc", - ResourceName: "site-vpc", - ProviderModule: "do-prod", - FieldValues: map[string]string{ - "provider": "do-prod", - "region": "nyc3", - "cidr": "10.10.0.0/16", - }, - Evidence: authzOK(), - } - out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) - if err != nil { - t.Fatalf("GenerateConfig: %v", err) - } - if out.Error != "" { - t.Errorf("unexpected error: %q", out.Error) - } - if out.YamlSnippet == "" { - t.Fatal("YamlSnippet empty") - } - - // Parse the YAML to assert shape. - var got map[string]any - if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { - t.Fatalf("YAML output not parseable: %v\n%s", err, out.YamlSnippet) - } - if got["name"] != "site-vpc" { - t.Errorf("name = %v, want site-vpc", got["name"]) - } - if got["type"] != "infra.vpc" { - t.Errorf("type = %v, want infra.vpc", got["type"]) - } - cfg, _ := got["config"].(map[string]any) - if cfg == nil { - t.Fatalf("config missing or wrong shape: %v", got["config"]) - } - if cfg["region"] != "nyc3" { - t.Errorf("config.region = %v, want nyc3", cfg["region"]) - } - if cfg["cidr"] != "10.10.0.0/16" { - t.Errorf("config.cidr = %v, want 10.10.0.0/16", cfg["cidr"]) - } -} - -// TestGenerateConfig_DefaultDeny pins the authz contract. -func TestGenerateConfig_DefaultDeny(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.vpc", - ResourceName: "site-vpc", - FieldValues: map[string]string{"region": "nyc3"}, - } // no Evidence - out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) - if out.Error == "" { - t.Error("expected non-empty Error on missing evidence") - } - if out.YamlSnippet != "" { - t.Errorf("YamlSnippet leaked on auth refusal: %q", out.YamlSnippet) - } -} - -// TestGenerateConfig_UnknownTypeReturnsValidationError pins the -// catalog-driven type guard. An unknown resource_type cannot be -// safely coerced; refuse with a clear message. -func TestGenerateConfig_UnknownTypeReturnsValidationError(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.not_real", - ResourceName: "x", - ProviderModule: "do-prod", - Evidence: authzOK(), - } - out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) - if out.Error == "" && len(out.ValidationErrors) == 0 { - t.Error("expected error or validation_errors for unknown resource_type") - } -} - -// TestGenerateConfig_BoolCoercion verifies `bool`-kind fields parse -// the form's string-encoded value ("true" / "false") to a proper YAML -// bool — the form-builder doesn't carry a typed map, so coercion -// lives in the handler. -func TestGenerateConfig_BoolCoercion(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.database", - ResourceName: "db", - ProviderModule: "do-prod", - FieldValues: map[string]string{ - "provider": "do-prod", - "region": "nyc3", - "engine": "postgres", - "size": "m", - "multi_az": "true", - }, - Evidence: authzOK(), - } - out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) - if err != nil { - t.Fatalf("GenerateConfig: %v", err) - } - var got map[string]any - if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { - t.Fatalf("YAML output not parseable: %v\n%s", err, out.YamlSnippet) - } - cfg, _ := got["config"].(map[string]any) - v, ok := cfg["multi_az"].(bool) - if !ok || !v { - t.Errorf("multi_az = %v (type %T), want bool true (catalog kind=bool coercion failed)", cfg["multi_az"], cfg["multi_az"]) - } -} - -// TestGenerateConfig_NumberCoercion verifies `number`-kind fields -// coerce to numeric YAML values. -func TestGenerateConfig_NumberCoercion(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.database", - ResourceName: "db", - ProviderModule: "do-prod", - FieldValues: map[string]string{ - "provider": "do-prod", - "region": "nyc3", - "engine": "postgres", - "size": "m", - "storage_gb": "100", - }, - Evidence: authzOK(), - } - out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) - var got map[string]any - if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { - t.Fatalf("YAML not parseable: %v", err) - } - cfg, _ := got["config"].(map[string]any) - // yaml.Unmarshal returns numeric values as int. - if v, ok := cfg["storage_gb"].(int); !ok || v != 100 { - t.Errorf("storage_gb = %v (type %T), want int 100", cfg["storage_gb"], cfg["storage_gb"]) - } -} - -// TestGenerateConfig_ArrayValuesJSONDecoded honors the cross-task -// contract locked in 2026-05-27: array_string field_values arrive -// JSON-encoded (e.g. `field_values["ingress"] = -// "[\"rule a\", \"rule b, c\"]"`) so values containing commas -// survive the wire. The handler decodes via json.Unmarshal. -func TestGenerateConfig_ArrayValuesJSONDecoded(t *testing.T) { - // FirewallConfig.ingress is array_string (FREEFORM_OK per design - // line 429). A rule DSL value can contain commas — pin the - // round-trip via JSON encoding. - rulesJSON, _ := json.Marshal([]string{ - "allow tcp 80", - "allow tcp 443,80", // contains comma — CSV would split this incorrectly - }) - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.firewall", - ResourceName: "fw", - ProviderModule: "do-prod", - FieldValues: map[string]string{ - "provider": "do-prod", - "region": "nyc3", - "ingress": string(rulesJSON), - }, - Evidence: authzOK(), - } - out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) - if err != nil { - t.Fatalf("GenerateConfig: %v", err) - } - if out.Error != "" { - t.Fatalf("unexpected error: %q", out.Error) - } - var got map[string]any - if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { - t.Fatalf("YAML not parseable: %v\n%s", err, out.YamlSnippet) - } - cfg, _ := got["config"].(map[string]any) - ingress, ok := cfg["ingress"].([]any) - if !ok { - t.Fatalf("ingress not a list: %v (type %T)", cfg["ingress"], cfg["ingress"]) - } - if len(ingress) != 2 { - t.Fatalf("ingress count = %d, want 2", len(ingress)) - } - if ingress[1] != "allow tcp 443,80" { - t.Errorf("ingress[1] = %v, want 'allow tcp 443,80' (comma-in-value lossless via JSON encoding)", ingress[1]) - } -} - -// TestGenerateConfig_PlainStringNotJSONDecoded verifies that when -// field_values carries a literal string for an array_string field -// (operator typed CSV manually, or a single-value submission), the -// handler accepts it gracefully — defensively wrap a non-JSON string -// into a one-element array. -// -// The JSON-encoded shape is canonical; the literal-string fallback is -// for safety so a malformed UI submission doesn't crash the server. -func TestGenerateConfig_PlainStringNotJSONDecoded(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.firewall", - ResourceName: "fw", - ProviderModule: "do-prod", - FieldValues: map[string]string{ - "provider": "do-prod", - "region": "nyc3", - "ingress": "allow tcp 80", // not JSON; defensive parse - }, - Evidence: authzOK(), - } - out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) - if err != nil { - t.Fatalf("GenerateConfig: %v", err) - } - if out.Error != "" { - t.Errorf("unexpected error: %q", out.Error) - } - var got map[string]any - yaml.Unmarshal([]byte(out.YamlSnippet), &got) - cfg, _ := got["config"].(map[string]any) - ingress, _ := cfg["ingress"].([]any) - if len(ingress) != 1 || ingress[0] != "allow tcp 80" { - t.Errorf("ingress = %v, want one-element array [allow tcp 80] (defensive wrap)", ingress) - } -} - -// TestGenerateConfig_NoFmtSprintfUserInput is the strict-contract -// guard from plan §Task 6: GenerateConfig MUST NOT use fmt.Sprintf -// on user input to construct YAML. Verified by submitting a value -// that would mangle YAML if string-interpolated (line breaks, YAML -// reserved chars) and asserting the output still parses + the value -// round-trips intact. -func TestGenerateConfig_NoFmtSprintfUserInput(t *testing.T) { - maliciousName := "x: y\n injected: true" - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.storage", - ResourceName: "store", - ProviderModule: "do-prod", - FieldValues: map[string]string{ - "provider": "do-prod", - "region": "nyc3", - "name": maliciousName, - "class": "standard", - }, - Evidence: authzOK(), - } - out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) - if err != nil { - t.Fatalf("GenerateConfig: %v", err) - } - if out.Error != "" { - t.Errorf("unexpected error: %q", out.Error) - } - var got map[string]any - if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { - t.Fatalf("YAML not parseable — possible Sprintf injection: %v\n%s", err, out.YamlSnippet) - } - cfg, _ := got["config"].(map[string]any) - if cfg["name"] != maliciousName { - t.Errorf("name not round-tripped intact (Sprintf injection?): got %v, want %v", cfg["name"], maliciousName) - } - if _, leaked := cfg["injected"]; leaked { - t.Error("'injected' key leaked into config — yaml.Marshal not used (Sprintf injection succeeded)") - } -} - -// TestGenerateConfig_OutputIsAMapModuleEntry verifies the YAML -// produced is a single module-entry shape (with name + type + -// config), NOT wrapped in `modules: [...]`. The form-builder's -// "copy" button + the docs expect the user to paste under their -// existing `modules:` block. -func TestGenerateConfig_OutputIsAMapModuleEntry(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.vpc", - ResourceName: "vpc1", - ProviderModule: "do-prod", - FieldValues: map[string]string{"provider": "do-prod", "region": "nyc3"}, - Evidence: authzOK(), - } - out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) - if strings.HasPrefix(strings.TrimSpace(out.YamlSnippet), "modules:") { - t.Errorf("YAML wraps in modules: — should be a bare module entry. Got:\n%s", out.YamlSnippet) - } - if !strings.Contains(out.YamlSnippet, "name: vpc1") { - t.Errorf("YAML missing name: vpc1\n%s", out.YamlSnippet) - } -} diff --git a/iac/admin/handler/get_resource.go b/iac/admin/handler/get_resource.go deleted file mode 100644 index 624a9531..00000000 --- a/iac/admin/handler/get_resource.go +++ /dev/null @@ -1,129 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "sort" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" - "github.com/GoCodeAlone/workflow/secrets" -) - -// GetResource implements InfraAdminService.GetResource by reading -// the named ResourceState from the host's iac.state backend and -// projecting it into an AdminResourceDetail. AppliedConfig is -// JSON-encoded into applied_config_json verbatim; Outputs are -// MASKED via secrets.MaskSensitiveOutputs against -// secrets.DefaultSensitiveKeys() and then JSON-encoded into -// outputs_json. The masked-key names are surfaced in -// sensitive_outputs_redacted so the UI can render a "redacted" -// affordance per design §Secret redaction: -// -// "GetResource.outputs_json redacts keys matching -// secrets.DefaultSensitiveKeys()." -// -// Per design §Authz row: default-deny when evidence is missing or -// either authz_checked / authz_allowed is false; refusal surfaces -// via Output.error rather than a Go-level error. -// -// Not-found surfaces via Output.error too — the design treats -// missing resources as a non-exceptional condition the UI must -// handle (e.g. a stale URL after a destroy). -func GetResource( - ctx context.Context, - store interfaces.IaCStateStore, - in *adminpb.AdminGetResourceInput, -) (*adminpb.AdminGetResourceOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminGetResourceOutput{Error: msg}, nil - } - if store == nil { - return &adminpb.AdminGetResourceOutput{Error: "get: no state store configured"}, nil - } - - state, err := store.GetResource(ctx, in.GetName()) - if err != nil { - // Intentional nilerr — see list_resources.go::ListResources for - // the proto tag-100 rationale (errors surface via Output.error, - // not Go-level errors, so HTTP transport returns 200 OK + typed - // payload). - return &adminpb.AdminGetResourceOutput{Error: "get state: " + err.Error()}, nil //nolint:nilerr - } - if state == nil { - return &adminpb.AdminGetResourceOutput{Error: "resource not found: " + in.GetName()}, nil - } - - appliedJSON, err := json.Marshal(state.AppliedConfig) - if err != nil { - return &adminpb.AdminGetResourceOutput{Error: "marshal applied_config: " + err.Error()}, nil //nolint:nilerr - } - - maskedOutputs, redactedKeys := maskOutputsForWire(state.Outputs) - outputsJSON, err := json.Marshal(maskedOutputs) - if err != nil { - return &adminpb.AdminGetResourceOutput{Error: "marshal outputs: " + err.Error()}, nil //nolint:nilerr - } - - detail := &adminpb.AdminResourceDetail{ - Summary: stateToSummary(state), - AppliedConfigJson: appliedJSON, - OutputsJson: outputsJSON, - ConfigHash: state.ConfigHash, - SensitiveOutputsRedacted: redactedKeys, - } - // Guard against zero time.Time → year-1 BCE Unix epoch (per - // code-reviewer T5 M-2). Resources that have never been drift- - // checked carry a zero LastDriftCheck; leave the proto field at - // 0 so the JS fmtTs helper's `!unix` check renders "—". - if !state.LastDriftCheck.IsZero() { - detail.LastDriftCheckUnix = state.LastDriftCheck.Unix() - } - return &adminpb.AdminGetResourceOutput{Resource: detail}, nil -} - -// maskOutputsForWire returns the masked outputs map + the sorted list -// of keys that WERE masked. The masking itself is delegated to -// secrets.MaskSensitiveOutputs so the handler library and any other -// caller of that helper agree byte-for-byte on the redaction -// algorithm — single source of truth. The handler's own contract -// (per design §Secret redaction) is the additional -// `sensitive_outputs_redacted` list, which the helper does NOT -// surface; we compute it here via one extra pass over the map. -// -// Per code-reviewer T5 I-1 (commit 5fe88fe45): an earlier draft -// hand-rolled the masking with a duplicate `(sensitive)` literal, -// which would have silently drifted if secrets ever extended its -// helper to do partial-value masking. Routing through -// secrets.MaskSensitiveOutputs eliminates that drift surface — same -// bug class as T1's sanitizeStateID allowlist-vs-replacer -// divergence the same reviewer caught earlier. -// -// "Sensitive" means a key matches one of secrets.DefaultSensitiveKeys() -// — the host-side authoritative list. Future enhancement: merge -// driver-specific keys via secrets.MergeSensitiveKeys; v1 sticks to -// defaults so the masking surface is stable across providers. -func maskOutputsForWire(outputs map[string]any) (map[string]any, []string) { - if len(outputs) == 0 { - return outputs, nil - } - keys := secrets.DefaultSensitiveKeys() - sensitiveSet := make(map[string]bool, len(keys)) - for _, k := range keys { - sensitiveSet[k] = true - } - var redacted []string - for k := range outputs { - if sensitiveSet[k] { - redacted = append(redacted, k) - } - } - if len(redacted) == 0 { - // No sensitive keys present — return original (no copy) so the - // caller cannot accidentally mutate the original state's - // outputs but we don't pay for an unused allocation either. - return outputs, nil - } - sort.Strings(redacted) // deterministic ordering for snapshot tests + diff-friendly UI - return secrets.MaskSensitiveOutputs(outputs, keys), redacted -} diff --git a/iac/admin/handler/get_resource_test.go b/iac/admin/handler/get_resource_test.go deleted file mode 100644 index 46553ac4..00000000 --- a/iac/admin/handler/get_resource_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package handler_test - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// seedDetailFixture returns a store with one resource carrying -// sensitive output keys + applied config so the GetResource test can -// exercise the redaction path. -func seedDetailFixture() *fakeStateStore { - now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) - return &fakeStateStore{ - resources: []interfaces.ResourceState{{ - ID: "db-prod", - Name: "db-prod", - Type: "infra.database", - Provider: "digitalocean", - ProviderRef: "do-prod", - ProviderID: "db-001", - ConfigHash: "sha256:cafef00d", - AppliedConfig: map[string]any{"engine": "postgres", "size": "m"}, - Outputs: map[string]any{ - "id": "db-001", - "endpoint": "db.internal:5432", - "password": "super-secret-pw", - "access_key": "AKIA...", - "private_key": "-----BEGIN...", - "plain_field": "ok-to-show", - }, - Dependencies: []string{"vpc-prod"}, - UpdatedAt: now, - LastDriftCheck: now.Add(-time.Hour), - }}, - } -} - -func TestGetResource_HappyPath(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{Name: "db-prod", Evidence: authzOK()} - out, err := handler.GetResource(context.Background(), store, in) - if err != nil { - t.Fatalf("GetResource: %v", err) - } - if out == nil || out.Resource == nil { - t.Fatal("nil output or detail with nil error") - } - d := out.Resource - if d.Summary == nil || d.Summary.Name != "db-prod" { - t.Errorf("summary.name = %v, want db-prod", d.Summary) - } - if d.ConfigHash != "sha256:cafef00d" { - t.Errorf("config_hash = %q, want sha256:cafef00d", d.ConfigHash) - } - if len(d.AppliedConfigJson) == 0 { - t.Error("applied_config_json empty; expected JSON-encoded AppliedConfig") - } - if len(d.OutputsJson) == 0 { - t.Error("outputs_json empty; expected JSON-encoded redacted outputs") - } -} - -func TestGetResource_RedactsSensitiveOutputs(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{Name: "db-prod", Evidence: authzOK()} - out, err := handler.GetResource(context.Background(), store, in) - if err != nil { - t.Fatalf("GetResource: %v", err) - } - - var outputs map[string]any - if err := json.Unmarshal(out.Resource.OutputsJson, &outputs); err != nil { - t.Fatalf("outputs_json not valid JSON: %v", err) - } - - // Sensitive keys must be masked. - sensitiveKeys := []string{"password", "access_key", "private_key"} - for _, k := range sensitiveKeys { - v, ok := outputs[k] - if !ok { - t.Errorf("output key %q dropped entirely; expected mask, not removal", k) - continue - } - if vs, _ := v.(string); strings.Contains(vs, "super-secret") || strings.Contains(vs, "AKIA") || strings.Contains(vs, "BEGIN") { - t.Errorf("output %q value not masked: %v", k, v) - } - } - - // Non-sensitive keys must pass through unchanged. - if outputs["id"] != "db-001" { - t.Errorf("id leaked or mangled: %v", outputs["id"]) - } - if outputs["endpoint"] != "db.internal:5432" { - t.Errorf("endpoint leaked or mangled: %v", outputs["endpoint"]) - } - if outputs["plain_field"] != "ok-to-show" { - t.Errorf("plain_field leaked or mangled: %v", outputs["plain_field"]) - } - - // sensitive_outputs_redacted must list each redacted key. - got := map[string]bool{} - for _, k := range out.Resource.SensitiveOutputsRedacted { - got[k] = true - } - for _, k := range sensitiveKeys { - if !got[k] { - t.Errorf("sensitive_outputs_redacted missing %q (got %v)", k, out.Resource.SensitiveOutputsRedacted) - } - } - // Should NOT list non-sensitive keys. - for _, k := range []string{"id", "endpoint", "plain_field"} { - if got[k] { - t.Errorf("sensitive_outputs_redacted leaked non-sensitive key %q", k) - } - } -} - -func TestGetResource_NotFound(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{Name: "does-not-exist", Evidence: authzOK()} - out, err := handler.GetResource(context.Background(), store, in) - if err != nil { - t.Fatalf("GetResource should not error on not-found — surfaces via Output.error: %v", err) - } - if out.Resource != nil { - t.Errorf("expected nil Resource on not-found, got %+v", out.Resource) - } - if out.Error == "" { - t.Error("expected non-empty Error on not-found") - } -} - -func TestGetResource_DefaultDenyOnMissingEvidence(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{Name: "db-prod"} // no Evidence - out, _ := handler.GetResource(context.Background(), store, in) - if out.Error == "" { - t.Error("expected non-empty Error on missing evidence (default-deny)") - } - if out.Resource != nil { - t.Errorf("expected nil Resource on auth refusal, got %+v", out.Resource) - } -} - -func TestGetResource_DefaultDenyOnAuthzNotChecked(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{ - Name: "db-prod", - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}, - } - out, _ := handler.GetResource(context.Background(), store, in) - if out.Error == "" { - t.Error("expected non-empty Error when authz_checked=false") - } -} - -func TestGetResource_DefaultDenyOnAuthzDenied(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{ - Name: "db-prod", - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}, - } - out, _ := handler.GetResource(context.Background(), store, in) - if out.Error == "" { - t.Error("expected non-empty Error when authz_allowed=false") - } -} - -func TestGetResource_PopulatesSummaryFields(t *testing.T) { - store := seedDetailFixture() - in := &adminpb.AdminGetResourceInput{Name: "db-prod", Evidence: authzOK()} - out, _ := handler.GetResource(context.Background(), store, in) - s := out.Resource.Summary - if s.ProviderType != "digitalocean" { - t.Errorf("provider_type = %q, want digitalocean", s.ProviderType) - } - if s.ProviderModule != "do-prod" { - t.Errorf("provider_module = %q, want do-prod", s.ProviderModule) - } - if s.ProviderId != "db-001" { - t.Errorf("provider_id = %q, want db-001", s.ProviderId) - } - if len(s.Dependencies) != 1 || s.Dependencies[0] != "vpc-prod" { - t.Errorf("dependencies = %v, want [vpc-prod]", s.Dependencies) - } -} - -// TestGetResource_NilAppliedConfig pins the json.Marshal(nil) → -// "null" path so the JS receives a parseable JSON literal rather -// than an empty payload. Per code-reviewer T5 M-3 (commit 5fe88fe45). -func TestGetResource_NilAppliedConfig(t *testing.T) { - store := &fakeStateStore{ - resources: []interfaces.ResourceState{{ - Name: "nil-cfg", - Type: "infra.vpc", - Provider: "stub", - ProviderRef: "stub-mod", - AppliedConfig: nil, // explicit nil - Outputs: nil, - }}, - } - in := &adminpb.AdminGetResourceInput{Name: "nil-cfg", Evidence: authzOK()} - out, err := handler.GetResource(context.Background(), store, in) - if err != nil { - t.Fatalf("GetResource: %v", err) - } - if out.Error != "" { - t.Errorf("unexpected error: %q", out.Error) - } - if string(out.Resource.AppliedConfigJson) != "null" { - t.Errorf("applied_config_json = %q, want \"null\" (JSON encoding of nil map)", string(out.Resource.AppliedConfigJson)) - } - if string(out.Resource.OutputsJson) != "null" { - t.Errorf("outputs_json = %q, want \"null\"", string(out.Resource.OutputsJson)) - } -} - -// TestGetResource_ZeroLastDriftCheckEmitsZero pins the T5 M-2 fix: -// a zero LastDriftCheck.Time MUST emit 0 (not the year-1-BCE Unix -// epoch -6795364578871), so the JS `!unix` guard renders "—" rather -// than "0001-01-01T00:00:00.000Z". -func TestGetResource_ZeroLastDriftCheckEmitsZero(t *testing.T) { - store := &fakeStateStore{ - resources: []interfaces.ResourceState{{ - Name: "never-checked", - Type: "infra.vpc", - Provider: "stub", - ProviderRef: "stub-mod", - AppliedConfig: map[string]any{}, - // LastDriftCheck unset → zero value - }}, - } - in := &adminpb.AdminGetResourceInput{Name: "never-checked", Evidence: authzOK()} - out, err := handler.GetResource(context.Background(), store, in) - if err != nil { - t.Fatalf("GetResource: %v", err) - } - if out.Resource.LastDriftCheckUnix != 0 { - t.Errorf("LastDriftCheckUnix = %d, want 0 for zero time.Time", out.Resource.LastDriftCheckUnix) - } - if out.Resource.Summary.UpdatedAtUnix != 0 { - t.Errorf("UpdatedAtUnix = %d, want 0 for zero time.Time", out.Resource.Summary.UpdatedAtUnix) - } -} diff --git a/iac/admin/handler/list_providers.go b/iac/admin/handler/list_providers.go deleted file mode 100644 index 8ee2968a..00000000 --- a/iac/admin/handler/list_providers.go +++ /dev/null @@ -1,123 +0,0 @@ -package handler - -import ( - "context" - "sort" - "strings" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -type providerRegionLister interface { - ListProviderRegions(context.Context, string) ([]string, error) -} - -// ListProviders implements InfraAdminService.ListProviders by -// walking the provided `providers` map (keyed by host YAML module -// name) and emitting one AdminProviderSummary per entry. The -// summary carries the YAML-config provider_type string from the -// caller-supplied providerTypeByModule map (NOT provider.Name() — -// see invariant below), the catalogued region + engine lists for -// that provider type, and the full catalog type list as -// supported_types. -// -// **provider_type MUST come from the YAML config string, not -// provider.Name()** — per spec-reviewer T6 F1 (commit 1ea231fdd) + -// design cycle-5/6 backports: -// - interfaces.IaCProvider.Name() returns the plugin's DISPLAY -// name (e.g. "DigitalOcean Provider"). This is operator-facing -// decoration, not a stable identifier. -// - The YAML-config provider: field (e.g. "digitalocean") is the -// stable identifier the region + engine catalogs key against. -// - The host module (T15) reads each iac.provider module's -// config at Init time and populates providerTypeByModule -// keyed by module-name → provider-type-string. -// - If providerTypeByModule[modName] is missing (e.g. a stale -// module loaded without re-Init), provider_type stays empty -// and SupportedRegions + SupportedEngines come back empty — -// UI degrades gracefully rather than rendering wrong dropdowns. -// -// Signature deviation from design §Handler library (informational — -// not blocking): the design listed -// -// ListProviders(ctx, providers, regionCat, in) -// -// The proto's AdminProviderSummary requires supported_engines + -// supported_types (so fieldCat + engineCat are needed) AND the F1 -// fix requires providerTypeByModule. Final shape is 7 params; -// design line 233 was underspecified. -// -// regions_source is "provider-lister" when the provider advertises and -// successfully serves IaCProviderRegionLister; otherwise it remains the -// literal "local-catalog" fallback. -// -// Per design §Authz: default-deny via the shared authz guard. -func ListProviders( - ctx context.Context, - providers map[string]interfaces.IaCProvider, //nolint:revive // reserved for symmetry + future per-provider RPCs (e.g. live capability probe) - providerTypeByModule map[string]string, - fieldCat *catalog.FieldSpecCatalog, - regionCat *catalog.RegionCatalog, - engineCat *catalog.EngineCatalog, - in *adminpb.AdminListProvidersInput, -) (*adminpb.AdminListProvidersOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminListProvidersOutput{Error: msg}, nil - } - - // Sort module names so the output ordering is deterministic. - // Downstream snapshot tests + the form-builder dropdown order - // both benefit from a stable iteration order; map iteration is - // random in Go. - moduleNames := make([]string, 0, len(providers)) - for name := range providers { - moduleNames = append(moduleNames, name) - } - sort.Strings(moduleNames) - - // supported_types is catalog-derived and uniform across providers - // in v1 (every typed Config can be applied to every iac.provider - // per the design's FieldSpec table). Cache the sorted list once - // rather than re-deriving per provider. - allTypes := fieldCat.AllTypes() - - out := &adminpb.AdminListProvidersOutput{} - for _, modName := range moduleNames { - providerType := providerTypeByModule[modName] // may be "" if Init didn't populate - supportedRegions := regionCat.For(providerType) - regionsSource := "local-catalog" - if lister, ok := providers[modName].(providerRegionLister); ok { - if regions, err := lister.ListProviderRegions(ctx, in.GetEnvName()); err == nil { - supportedRegions = normalizeProviderRegions(regions) - regionsSource = "provider-lister" - } - } - summary := &adminpb.AdminProviderSummary{ - ModuleName: modName, - ProviderType: providerType, - SupportedRegions: supportedRegions, - SupportedEngines: engineCat.For(providerType), - SupportedTypes: append([]string(nil), allTypes...), - RegionsSource: regionsSource, - } - out.Providers = append(out.Providers, summary) - } - return out, nil -} - -func normalizeProviderRegions(regions []string) []string { - out := make([]string, 0, len(regions)) - seen := make(map[string]bool, len(regions)) - for _, region := range regions { - region = strings.TrimSpace(region) - if region == "" || seen[region] { - continue - } - seen[region] = true - out = append(out, region) - } - sort.Strings(out) - return out -} diff --git a/iac/admin/handler/list_providers_test.go b/iac/admin/handler/list_providers_test.go deleted file mode 100644 index d7630fa4..00000000 --- a/iac/admin/handler/list_providers_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package handler_test - -import ( - "context" - "errors" - "sort" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// nameableProvider is the minimal interfaces.IaCProvider that -// list_providers_test needs. Only Name() returns a useful value — the -// rest exist to satisfy the interface. Distinct from stubProvider in -// provider_test.go so the two test files don't collide on type names. -type nameableProvider struct{ name string } - -func (p *nameableProvider) Name() string { return p.name } -func (p *nameableProvider) Version() string { return "test" } -func (p *nameableProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } -func (p *nameableProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } -func (p *nameableProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) Import(_ context.Context, _, _ string) (*interfaces.ResourceState, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { - return nil, errors.New("stub") -} -func (p *nameableProvider) SupportedCanonicalKeys() []string { return nil } -func (p *nameableProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { - return nil, nil -} -func (p *nameableProvider) Close() error { return nil } - -type regionListingProvider struct { - *nameableProvider - regions []string - err error -} - -func (p *regionListingProvider) ListProviderRegions(_ context.Context, _ string) ([]string, error) { - return append([]string(nil), p.regions...), p.err -} - -// providersFixture returns the providers map + a parallel -// providerTypeByModule map captured "as if" at module Init from the -// host YAML config. Per spec-reviewer T6 F1: the test fixture must -// separate the two so a regression that mistakenly uses provider.Name() -// can't be masked by a fake that happens to return the YAML-config -// string. We deliberately set the fake Names to DIFFERENT, -// DISPLAY-style strings so the bug would surface as wrong -// provider_type / empty region+engine lists. -func providersFixture() (map[string]interfaces.IaCProvider, map[string]string) { - providers := map[string]interfaces.IaCProvider{ - "do-prod": &nameableProvider{name: "DigitalOcean Provider"}, // display name - "aws-prod": &nameableProvider{name: "AWS Provider Plugin"}, // display name - "stub-tester": &nameableProvider{name: "Stub IaC Provider"}, // display name - } - providerTypeByModule := map[string]string{ - "do-prod": "digitalocean", // YAML-config string (stable identifier) - "aws-prod": "aws", - "stub-tester": "stub", - } - return providers, providerTypeByModule -} - -func TestListProviders_HappyPath(t *testing.T) { - providers, providerTypeByModule := providersFixture() - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, err := handler.ListProviders( - context.Background(), - providers, - providerTypeByModule, - catalog.New(), - catalog.NewRegionCatalog(), - catalog.NewEngineCatalog(), - in, - ) - if err != nil { - t.Fatalf("ListProviders: %v", err) - } - if out.Error != "" { - t.Errorf("unexpected error: %q", out.Error) - } - if len(out.Providers) != len(providers) { - t.Fatalf("got %d providers, want %d", len(out.Providers), len(providers)) - } -} - -func TestListProviders_PopulatesRegionsAndEnginesAndTypes(t *testing.T) { - providers, providerTypeByModule := providersFixture() - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), providers, providerTypeByModule, - catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, - ) - var doProv *adminpb.AdminProviderSummary - for _, p := range out.Providers { - if p.ModuleName == "do-prod" { - doProv = p - } - } - if doProv == nil { - t.Fatal("do-prod missing from result") - } - if doProv.ProviderType != "digitalocean" { - t.Errorf("ProviderType = %q, want digitalocean (from providerTypeByModule, NOT provider.Name())", doProv.ProviderType) - } - if doProv.RegionsSource != "local-catalog" { - t.Errorf("RegionsSource = %q, want local-catalog (v1 per design)", doProv.RegionsSource) - } - if len(doProv.SupportedRegions) == 0 { - t.Error("SupportedRegions empty for digitalocean — region catalog lookup failed") - } - if !contains(doProv.SupportedRegions, "nyc3") { - t.Errorf("SupportedRegions missing nyc3: %v", doProv.SupportedRegions) - } - if len(doProv.SupportedEngines) == 0 { - t.Error("SupportedEngines empty for digitalocean — engine catalog lookup failed") - } - if len(doProv.SupportedTypes) == 0 { - t.Error("SupportedTypes empty — fieldCat reverse-index produced no types") - } - // All 13 catalog types should be present in supported_types since v1 - // of the catalog treats every type as cross-provider. - if len(doProv.SupportedTypes) < 13 { - t.Errorf("SupportedTypes count = %d, want >= 13 (full catalog)", len(doProv.SupportedTypes)) - } -} - -func TestListProviders_UsesProviderRegionListerWhenAvailable(t *testing.T) { - providers := map[string]interfaces.IaCProvider{ - "do-prod": ®ionListingProvider{ - nameableProvider: &nameableProvider{name: "DigitalOcean Provider"}, - regions: []string{"sfo3", "nyc3"}, - }, - } - providerTypeByModule := map[string]string{"do-prod": "digitalocean"} - in := &adminpb.AdminListProvidersInput{EnvName: "staging", Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), - providers, - providerTypeByModule, - catalog.New(), - catalog.NewRegionCatalog(), - catalog.NewEngineCatalog(), - in, - ) - if len(out.Providers) != 1 { - t.Fatalf("got %d providers, want 1", len(out.Providers)) - } - got := out.Providers[0] - if got.RegionsSource != "provider-lister" { - t.Fatalf("RegionsSource = %q, want provider-lister", got.RegionsSource) - } - want := []string{"nyc3", "sfo3"} - if !equalStrings(got.SupportedRegions, want) { - t.Fatalf("SupportedRegions = %v, want provider lister regions %v", got.SupportedRegions, want) - } -} - -func TestListProviders_RegionListerErrorFallsBackToLocalCatalog(t *testing.T) { - providers := map[string]interfaces.IaCProvider{ - "do-prod": ®ionListingProvider{ - nameableProvider: &nameableProvider{name: "DigitalOcean Provider"}, - err: errors.New("credentials unavailable"), - }, - } - providerTypeByModule := map[string]string{"do-prod": "digitalocean"} - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), - providers, - providerTypeByModule, - catalog.New(), - catalog.NewRegionCatalog(), - catalog.NewEngineCatalog(), - in, - ) - if len(out.Providers) != 1 { - t.Fatalf("got %d providers, want 1", len(out.Providers)) - } - got := out.Providers[0] - if got.RegionsSource != "local-catalog" { - t.Fatalf("RegionsSource = %q, want local-catalog fallback", got.RegionsSource) - } - if !contains(got.SupportedRegions, "nyc3") { - t.Fatalf("SupportedRegions = %v, want local catalog fallback including nyc3", got.SupportedRegions) - } -} - -// TestListProviders_UsesCapturedConfigStringNotProviderName is the -// regression guard for spec-reviewer T6 F1 (commit 1ea231fdd): -// provider.Name() returns the plugin's display name in production; -// the YAML-config provider: string (captured at module Init via -// providerTypeByModule) is what the catalogs key against. If the -// handler ever reverts to p.Name(), this test fails because the -// fake's Name() returns "DigitalOcean Provider" — not in any -// catalog — so SupportedRegions + SupportedEngines come back empty. -func TestListProviders_UsesCapturedConfigStringNotProviderName(t *testing.T) { - providers, providerTypeByModule := providersFixture() - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), providers, providerTypeByModule, - catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, - ) - var doProv *adminpb.AdminProviderSummary - for _, p := range out.Providers { - if p.ModuleName == "do-prod" { - doProv = p - } - } - if doProv == nil { - t.Fatal("do-prod missing") - } - // Bug-class assertion: handler MUST NOT carry the display name - // from provider.Name() into provider_type. The fixture's fake - // Name() returns "DigitalOcean Provider" — if that leaks - // through, the catalog lookup downstream will fail and - // SupportedRegions will be empty. - if doProv.ProviderType == "DigitalOcean Provider" { - t.Fatal("BUG: provider_type carries provider.Name() (display name) instead of providerTypeByModule (YAML config string)") - } - if doProv.ProviderType != "digitalocean" { - t.Errorf("ProviderType = %q, want exactly 'digitalocean'", doProv.ProviderType) - } - if len(doProv.SupportedRegions) == 0 { - t.Error("SupportedRegions empty — provider_type isn't a catalog key (the F1 bug symptom)") - } -} - -// TestListProviders_MissingProviderTypeByModule_DegradesGracefully -// guards the F1 fix's degradation path: when providerTypeByModule -// doesn't include a key for a registered iac.provider module (e.g. -// stale Init, hot-reload race), the handler still emits a summary -// entry with empty provider_type + empty regions/engines so the UI -// renders a graceful empty-dropdown affordance instead of crashing -// or dropping the provider entirely. -func TestListProviders_MissingProviderTypeByModule_DegradesGracefully(t *testing.T) { - providers, _ := providersFixture() - // Pass an EMPTY map — simulates "Init never populated for these modules". - emptyTypeMap := map[string]string{} - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), providers, emptyTypeMap, - catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, - ) - if len(out.Providers) != len(providers) { - t.Fatalf("got %d providers, want %d (handler dropped entries instead of degrading)", len(out.Providers), len(providers)) - } - for _, p := range out.Providers { - if p.ProviderType != "" { - t.Errorf("module %q ProviderType = %q, want empty when providerTypeByModule key is missing", p.ModuleName, p.ProviderType) - } - if len(p.SupportedRegions) != 0 { - t.Errorf("module %q SupportedRegions = %v, want empty (regions keyed by empty string)", p.ModuleName, p.SupportedRegions) - } - } -} - -func TestListProviders_SortedByModuleName(t *testing.T) { - providers, providerTypeByModule := providersFixture() - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), providers, providerTypeByModule, - catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, - ) - gotNames := make([]string, 0, len(out.Providers)) - for _, p := range out.Providers { - gotNames = append(gotNames, p.ModuleName) - } - if !sort.StringsAreSorted(gotNames) { - t.Errorf("ListProviders not sorted by module_name: %v", gotNames) - } -} - -func TestListProviders_DefaultDeny(t *testing.T) { - providers, providerTypeByModule := providersFixture() - cases := []struct { - name string - ev *adminpb.AdminAuthzEvidence - }{ - {"nil", nil}, - {"checked=false", &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}}, - {"allowed=false", &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - in := &adminpb.AdminListProvidersInput{Evidence: c.ev} - out, _ := handler.ListProviders( - context.Background(), providers, providerTypeByModule, - catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, - ) - if out.Error == "" { - t.Error("expected non-empty Error on default-deny") - } - if len(out.Providers) != 0 { - t.Errorf("expected empty Providers on refusal, got %d", len(out.Providers)) - } - }) - } -} - -func TestListProviders_UnknownProviderTypeStillSurfaces(t *testing.T) { - // A registered iac.provider module whose YAML-config provider: - // string isn't in the region/engine catalog should still appear - // in the listing, but with empty supported_regions / - // supported_engines (so the UI can render a "no regions available" - // placeholder rather than dropping the provider). - providers := map[string]interfaces.IaCProvider{ - "unknown-mod": &nameableProvider{name: "Mystery Cloud Provider"}, // display name; irrelevant - } - providerTypeByModule := map[string]string{ - "unknown-mod": "mystery-cloud", // YAML config string; not in any catalog - } - in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} - out, _ := handler.ListProviders( - context.Background(), providers, providerTypeByModule, - catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, - ) - if len(out.Providers) != 1 { - t.Fatalf("got %d providers, want 1", len(out.Providers)) - } - p := out.Providers[0] - if p.ProviderType != "mystery-cloud" { - t.Errorf("ProviderType = %q, want mystery-cloud (from providerTypeByModule)", p.ProviderType) - } - if len(p.SupportedRegions) != 0 { - t.Errorf("SupportedRegions = %v, want empty (mystery-cloud not in catalog)", p.SupportedRegions) - } - if len(p.SupportedEngines) != 0 { - t.Errorf("SupportedEngines = %v, want empty (mystery-cloud not in catalog)", p.SupportedEngines) - } - // supported_types is catalog-derived (NOT per-provider) in v1 so - // it stays populated even for uncatalogued provider types. - if len(p.SupportedTypes) == 0 { - t.Error("SupportedTypes empty — should still list the full catalog regardless of provider") - } -} - -func contains(slice []string, v string) bool { - for _, s := range slice { - if s == v { - return true - } - } - return false -} - -func equalStrings(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/iac/admin/handler/list_resources.go b/iac/admin/handler/list_resources.go deleted file mode 100644 index 3915c5cf..00000000 --- a/iac/admin/handler/list_resources.go +++ /dev/null @@ -1,134 +0,0 @@ -package handler - -import ( - "context" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// ListResources implements InfraAdminService.ListResources by reading -// every ResourceState from the host's iac.state backend, applying the -// type / provider / app-context filters from the input, and -// returning AdminResourceSummary rows (no per-resource secrets — -// outputs are intentionally absent from the list view; the detail -// view uses GetResource). -// -// Per design §Handler library + plan §Task 5: the function takes -// `providers` + `catalog` as parameters so the host module and CLI -// callers can share one dispatch. v1 of this list endpoint does not -// consult providers or catalog directly — every populated field on -// AdminResourceSummary derives from the ResourceState itself — but -// the parameters are preserved in the signature for symmetry with -// ListResourceTypes / GenerateConfig and to keep the dispatch shape -// stable when T6 / future enhancements need to cross-check against -// the live providers map (e.g. dropping resources whose -// state.ProviderRef no longer matches a registered module). -// -// Per design §Authz row: default-deny when evidence is missing or -// either authz_checked / authz_allowed is false; refusal surfaces -// via Output.error rather than a Go-level error so the HTTP -// transport returns 200 OK with the typed payload (consumers sniff -// for non-empty error per the proto tag-100 discriminator). -func ListResources( - ctx context.Context, - store interfaces.IaCStateStore, - providers map[string]interfaces.IaCProvider, //nolint:revive // reserved for symmetry + future use; see godoc - fieldCat *catalog.FieldSpecCatalog, //nolint:revive // reserved for symmetry + future use; see godoc - in *adminpb.AdminListResourcesInput, -) (*adminpb.AdminListResourcesOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminListResourcesOutput{Error: msg}, nil - } - if store == nil { - return &adminpb.AdminListResourcesOutput{Error: "list: no state store configured"}, nil - } - - states, err := store.ListResources(ctx) - if err != nil { - // Intentional nilerr: per proto tag-100 convention, errors - // surface via Output.error (HTTP transport returns 200 + typed - // payload; consumers sniff for non-empty error). A Go-level - // error would force the HTTP layer into a 5xx and lose the - // typed shape. - return &adminpb.AdminListResourcesOutput{Error: "list state: " + err.Error()}, nil //nolint:nilerr - } - - out := &adminpb.AdminListResourcesOutput{} - for i := range states { - s := &states[i] - summary := stateToSummary(s) - - if in.GetTypeFilter() != "" && summary.Type != in.GetTypeFilter() { - continue - } - if in.GetProviderFilter() != "" && summary.ProviderModule != in.GetProviderFilter() { - continue - } - if in.GetAppContextFilter() != "" && summary.AppContext != in.GetAppContextFilter() { - continue - } - - out.Resources = append(out.Resources, summary) - } - return out, nil -} - -// stateToSummary projects a ResourceState into the AdminResourceSummary -// wire shape. Shared with GetResource (which wraps the same summary -// inside AdminResourceDetail) — extracted so the field mapping is -// single-sourced. -// -// Provider fields: -// - ProviderModule ← state.ProviderRef (host YAML `name:` of the -// iac.provider module that owns this resource) -// - ProviderType ← state.Provider (cloud provider string, -// e.g. "digitalocean", "aws") -// -// app_context derives from state.AppliedConfig["labels"]["app_context"] -// per design §App context. Falls back to empty string when the label -// is absent (so AppContextFilter == "" matches every resource). -// -// Status is hardcoded "active" in v1 because interfaces.ResourceState -// has no Status field; the on-disk StateRecord.Status IS captured by -// the wfctlhelpers fs/postgres backends but dropped during the -// ResourceState conversion. Promoting Status into interfaces. -// ResourceState is a v1.1 item that lands alongside reconciliation -// (design §Personas explicitly excludes mid-cycle states from v1). -// Per spec-reviewer + code-reviewer T5 M-1 on commit 5fe88fe45. -func stateToSummary(s *interfaces.ResourceState) *adminpb.AdminResourceSummary { - out := &adminpb.AdminResourceSummary{ - Name: s.Name, - Type: s.Type, - ProviderModule: s.ProviderRef, - ProviderType: s.Provider, - ProviderId: s.ProviderID, - Status: "active", // TODO(v1.1): promote Status to interfaces.ResourceState - Dependencies: append([]string(nil), s.Dependencies...), - AppContext: extractAppContext(s.AppliedConfig), - } - // Guard against zero time.Time → year-1 BCE Unix epoch (per - // code-reviewer T5 M-2). The JS fmtTs helper checks `!unix`, so - // a 0 here renders as "—" rather than a misleading "0001-01-01". - if !s.UpdatedAt.IsZero() { - out.UpdatedAtUnix = s.UpdatedAt.Unix() - } - return out -} - -// extractAppContext digs labels.app_context out of an AppliedConfig -// map. The labels sub-map is the design's convention for free-form -// resource tagging; v1 supports just "app_context" but the shape is -// future-proof for additional well-known labels. -func extractAppContext(cfg map[string]any) string { - if cfg == nil { - return "" - } - labels, _ := cfg["labels"].(map[string]any) - if labels == nil { - return "" - } - v, _ := labels["app_context"].(string) - return v -} diff --git a/iac/admin/handler/list_resources_test.go b/iac/admin/handler/list_resources_test.go deleted file mode 100644 index 24e4f6f7..00000000 --- a/iac/admin/handler/list_resources_test.go +++ /dev/null @@ -1,403 +0,0 @@ -package handler_test - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// fakeStateStore is a minimal interfaces.IaCStateStore for handler -// tests. Only the read-side subset {GetResource, ListResources} is -// exercised by the handler library; SaveResource is used by the test -// fixture setup. Out-of-subset methods panic per the wfctlhelpers -// design-cycle-5 convention so accidental misuse is loud. -type fakeStateStore struct { - resources []interfaces.ResourceState -} - -func (s *fakeStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { - out := make([]interfaces.ResourceState, len(s.resources)) - copy(out, s.resources) - return out, nil -} -func (s *fakeStateStore) GetResource(_ context.Context, name string) (*interfaces.ResourceState, error) { - for i := range s.resources { - if s.resources[i].Name == name { - r := s.resources[i] - return &r, nil - } - } - return nil, nil -} -func (s *fakeStateStore) SaveResource(_ context.Context, state interfaces.ResourceState) error { - s.resources = append(s.resources, state) - return nil -} -func (s *fakeStateStore) DeleteResource(_ context.Context, _ string) error { return nil } -func (s *fakeStateStore) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { - panic("fakeStateStore: SavePlan out-of-subset") -} -func (s *fakeStateStore) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) { - panic("fakeStateStore: GetPlan out-of-subset") -} -func (s *fakeStateStore) Lock(_ context.Context, _ string, _ time.Duration) (interfaces.IaCLockHandle, error) { - panic("fakeStateStore: Lock out-of-subset") -} -func (s *fakeStateStore) Close() error { return nil } - -// authzOK is the standard "host authz middleware ran + allowed" -// evidence pinned for happy-path tests. Default-deny tests pass nil -// or a partial evidence to trigger the refusal branch. -func authzOK() *adminpb.AdminAuthzEvidence { - return &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: "user:alice", - GrantedPermissions: []string{"infra:read"}, - } -} - -// planningProvider is a minimal interfaces.IaCProvider for handler tests. -// It replaces the deleted iac/stubprovider package — scenario fixtures must -// not live in the workflow engine core. Provides real Plan, Destroy, -// DetectDrift, and ResourceDriver behavior so tests can exercise the full -// dispatch path without an external package dependency. -type planningProvider struct{} - -var _ interfaces.IaCProvider = (*planningProvider)(nil) - -func (p *planningProvider) Name() string { return "test-planning" } -func (p *planningProvider) Version() string { return "0.0.0-test" } -func (p *planningProvider) Initialize(_ context.Context, _ map[string]any) error { - return nil -} -func (p *planningProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } - -func (p *planningProvider) Plan(_ context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (*interfaces.IaCPlan, error) { - currentByName := make(map[string]*interfaces.ResourceState, len(current)) - for i := range current { - currentByName[current[i].Name] = ¤t[i] - } - desiredByName := make(map[string]struct{}, len(desired)) - for _, s := range desired { - desiredByName[s.Name] = struct{}{} - } - plan := &interfaces.IaCPlan{} - for _, spec := range desired { - if _, exists := currentByName[spec.Name]; exists { - plan.Actions = append(plan.Actions, interfaces.PlanAction{Action: "update", Resource: spec, Current: currentByName[spec.Name]}) - } else { - plan.Actions = append(plan.Actions, interfaces.PlanAction{Action: "create", Resource: spec}) - } - } - for i := range current { - st := ¤t[i] - if _, wanted := desiredByName[st.Name]; !wanted { - plan.Actions = append(plan.Actions, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: st.Name, Type: st.Type}, Current: st}) - } - } - return plan, nil -} - -func (p *planningProvider) Destroy(_ context.Context, refs []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { - names := make([]string, 0, len(refs)) - for _, r := range refs { - names = append(names, r.Name) - } - return &interfaces.DestroyResult{Destroyed: names}, nil -} - -func (p *planningProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { - return nil, nil -} - -func (p *planningProvider) DetectDrift(_ context.Context, refs []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { - results := make([]interfaces.DriftResult, 0, len(refs)) - for _, r := range refs { - results = append(results, interfaces.DriftResult{Name: r.Name, Type: r.Type, Drifted: false, Class: interfaces.DriftClassInSync}) - } - return results, nil -} - -func (p *planningProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { - return nil, nil -} - -func (p *planningProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { - return nil, nil -} - -func (p *planningProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { - return &planningDriver{}, nil -} - -func (p *planningProvider) SupportedCanonicalKeys() []string { return nil } - -func (p *planningProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { - return nil, nil -} - -func (p *planningProvider) Close() error { return nil } - -// planningDriver is a minimal interfaces.ResourceDriver for handler tests. -type planningDriver struct{} - -var _ interfaces.ResourceDriver = (*planningDriver)(nil) - -func (d *planningDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "test-" + spec.Name}, nil -} - -func (d *planningDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { - return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID}, nil -} - -func (d *planningDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - pid := ref.ProviderID - if pid == "" { - pid = "test-" + spec.Name - } - return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: pid}, nil -} - -func (d *planningDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil } - -func (d *planningDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { - return &interfaces.DiffResult{NeedsUpdate: false, NeedsReplace: false}, nil -} - -func (d *planningDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { - return nil, nil -} - -func (d *planningDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { - return nil, nil -} - -func (d *planningDriver) SensitiveKeys() []string { return nil } - -// seedFixture returns a 3-resource store + label-bearing state covering -// the filter dimensions: type (infra.vpc vs infra.database), provider -// module (do-prod vs do-staging), and app_context (web vs api). -func seedFixture() *fakeStateStore { - now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) - return &fakeStateStore{ - resources: []interfaces.ResourceState{ - { - ID: "vpc-prod-web", - Name: "vpc-prod-web", - Type: "infra.vpc", - Provider: "digitalocean", - ProviderRef: "do-prod", - ProviderID: "vpc-001", - AppliedConfig: map[string]any{ - "region": "nyc3", - "labels": map[string]any{"app_context": "web"}, - }, - UpdatedAt: now, - }, - { - ID: "db-prod-web", - Name: "db-prod-web", - Type: "infra.database", - Provider: "digitalocean", - ProviderRef: "do-prod", - ProviderID: "db-001", - AppliedConfig: map[string]any{ - "engine": "postgres", - "labels": map[string]any{"app_context": "web"}, - }, - UpdatedAt: now.Add(time.Hour), - }, - { - ID: "vpc-staging-api", - Name: "vpc-staging-api", - Type: "infra.vpc", - Provider: "digitalocean", - ProviderRef: "do-staging", - ProviderID: "vpc-002", - AppliedConfig: map[string]any{ - "region": "ams3", - "labels": map[string]any{"app_context": "api"}, - }, - UpdatedAt: now.Add(2 * time.Hour), - }, - }, - } -} - -func TestListResources_HappyPath(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{Evidence: authzOK()} - out, err := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if err != nil { - t.Fatalf("ListResources: %v", err) - } - if out == nil { - t.Fatal("nil output with nil error") - } - if out.Error != "" { - t.Errorf("unexpected error field: %q", out.Error) - } - if len(out.Resources) != 3 { - t.Fatalf("got %d resources, want 3 (no filters applied)", len(out.Resources)) - } -} - -func TestListResources_DefaultDenyOnMissingEvidence(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{} // no Evidence - out, err := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if err != nil { - t.Fatalf("ListResources should NOT error on auth refusal — it returns Output.error: %v", err) - } - if out == nil { - t.Fatal("nil output with nil error on auth refusal") - } - if out.Error == "" { - t.Error("expected non-empty Error on missing evidence (default-deny)") - } - if len(out.Resources) != 0 { - t.Errorf("expected empty Resources on auth refusal, got %d", len(out.Resources)) - } -} - -func TestListResources_DefaultDenyOnAuthzNotChecked(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}, - } - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if out.Error == "" { - t.Error("expected non-empty Error when authz_checked=false (default-deny)") - } -} - -func TestListResources_DefaultDenyOnAuthzDenied(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}, - } - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if out.Error == "" { - t.Error("expected non-empty Error when authz_allowed=false") - } -} - -func TestListResources_TypeFilter(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{TypeFilter: "infra.vpc", Evidence: authzOK()} - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if len(out.Resources) != 2 { - t.Fatalf("got %d resources, want 2 (vpc-prod-web + vpc-staging-api)", len(out.Resources)) - } - for _, r := range out.Resources { - if r.Type != "infra.vpc" { - t.Errorf("type_filter leak: got %s", r.Type) - } - } -} - -func TestListResources_ProviderFilterByModuleName(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{ProviderFilter: "do-prod", Evidence: authzOK()} - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if len(out.Resources) != 2 { - t.Fatalf("got %d resources, want 2 (vpc-prod-web + db-prod-web)", len(out.Resources)) - } - for _, r := range out.Resources { - if r.ProviderModule != "do-prod" { - t.Errorf("provider_filter leak: got module %q", r.ProviderModule) - } - } -} - -func TestListResources_AppContextFilter(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{AppContextFilter: "api", Evidence: authzOK()} - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if len(out.Resources) != 1 { - t.Fatalf("got %d resources, want 1 (vpc-staging-api only)", len(out.Resources)) - } - if out.Resources[0].Name != "vpc-staging-api" { - t.Errorf("got %s, want vpc-staging-api", out.Resources[0].Name) - } - if out.Resources[0].AppContext != "api" { - t.Errorf("app_context not populated: got %q", out.Resources[0].AppContext) - } -} - -func TestListResources_CombinedFilters(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{ - TypeFilter: "infra.vpc", - ProviderFilter: "do-prod", - AppContextFilter: "web", - Evidence: authzOK(), - } - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if len(out.Resources) != 1 { - t.Fatalf("got %d resources, want 1 (vpc-prod-web only)", len(out.Resources)) - } - if out.Resources[0].Name != "vpc-prod-web" { - t.Errorf("got %s, want vpc-prod-web", out.Resources[0].Name) - } -} - -func TestListResources_PopulatesProviderTypeAndModule(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{TypeFilter: "infra.vpc", ProviderFilter: "do-prod", Evidence: authzOK()} - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if len(out.Resources) != 1 { - t.Fatalf("got %d, want 1", len(out.Resources)) - } - r := out.Resources[0] - if r.ProviderType != "digitalocean" { - t.Errorf("provider_type = %q, want digitalocean (from state.Provider)", r.ProviderType) - } - if r.ProviderModule != "do-prod" { - t.Errorf("provider_module = %q, want do-prod (from state.ProviderRef)", r.ProviderModule) - } -} - -func TestListResources_EmptyAppContextSurvivesFilter(t *testing.T) { - // Edge case: when AppContextFilter is empty, resources with empty - // app_context label must still pass through. - now := time.Now().UTC() - store := &fakeStateStore{ - resources: []interfaces.ResourceState{{ - Name: "no-context", Type: "infra.vpc", - Provider: "digitalocean", ProviderRef: "do-prod", - AppliedConfig: map[string]any{}, // no labels - UpdatedAt: now, - }}, - } - in := &adminpb.AdminListResourcesInput{Evidence: authzOK()} - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - if len(out.Resources) != 1 { - t.Errorf("empty AppContextFilter should pass through unlabeled resources; got %d", len(out.Resources)) - } -} - -// containsError fails the test when out.Error does not contain the -// expected substring. Used by default-deny tests to verify the -// operator-facing message is actionable. -func containsError(t *testing.T, out *adminpb.AdminListResourcesOutput, want string) { - t.Helper() - if !strings.Contains(out.Error, want) { - t.Errorf("Error = %q, want substring %q", out.Error, want) - } -} - -func TestListResources_DenyMessageMentionsAuthz(t *testing.T) { - store := seedFixture() - in := &adminpb.AdminListResourcesInput{} // no Evidence - out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) - containsError(t, out, "authz") -} diff --git a/iac/admin/handler/list_types.go b/iac/admin/handler/list_types.go deleted file mode 100644 index 0e1a7e42..00000000 --- a/iac/admin/handler/list_types.go +++ /dev/null @@ -1,91 +0,0 @@ -package handler - -import ( - "context" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// ListResourceTypes implements InfraAdminService.ListResourceTypes by -// walking the FieldSpecCatalog and emitting one -// AdminResourceTypeMetadata per registered type. Each metadata -// entry carries the catalog's full FieldSpec list so the new-resource -// form-builder UI can render the right inputs without an extra RPC -// per type. Per plan §Task 6 + design §Handler library. -// -// The providers parameter is reserved for symmetry with the other -// handler functions; v1 of this endpoint does not filter types by -// live providers (the FieldSpecCatalog is the authoritative type -// list and assumes every registered type is supportable by every -// known provider — see TestListProviders_PopulatesRegionsAndEngines -// AndTypes for the cross-task assumption). -// -// Per design §Authz: default-deny via the shared authz guard. -func ListResourceTypes( - ctx context.Context, - fieldCat *catalog.FieldSpecCatalog, - providers map[string]interfaces.IaCProvider, //nolint:revive // reserved for symmetry + future use - in *adminpb.AdminListResourceTypesInput, -) (*adminpb.AdminListResourceTypesOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminListResourceTypesOutput{Error: msg}, nil - } - - out := &adminpb.AdminListResourceTypesOutput{} - for _, typeName := range fieldCat.AllTypes() { - fields, ok := fieldCat.Get(typeName) - if !ok { - continue - } - out.Types = append(out.Types, &adminpb.AdminResourceTypeMetadata{ - Type: typeName, - ConfigMessageFqn: typeNameToConfigFQN(typeName), - Fields: projectFieldSpecs(fields), - SupportedProviders: nil, // v1: empty = "any catalogued provider"; see godoc on this function. - Description: "", // populated by future catalog enhancement. - }) - } - return out, nil -} - -// projectFieldSpecs converts the host-side FieldSpec slice into the -// wire-typed AdminFieldSpec slice field-for-field. Single-sourced -// here so the projection cannot drift across handlers. -func projectFieldSpecs(in []catalog.FieldSpec) []*adminpb.AdminFieldSpec { - out := make([]*adminpb.AdminFieldSpec, 0, len(in)) - for i := range in { - f := in[i] - out = append(out, &adminpb.AdminFieldSpec{ - Name: f.Name, - Label: f.Label, - Kind: f.Kind, - Required: f.Required, - EnumValues: append([]string(nil), f.EnumValues...), - EnumSource: f.EnumSource, - Description: f.Description, - DefaultValue: f.DefaultValue, - Sensitive: f.Sensitive, - ElementKind: f.ElementKind, - MinCount: f.MinCount, - MaxCount: f.MaxCount, - DependsOnField: f.DependsOnField, - }) - } - return out -} - -// typeNameToConfigFQN maps a catalog type name (e.g. "infra.vpc") -// to its fully-qualified proto message name in -// workflow-plugins-infra/internal/contracts/infra.proto (e.g. -// "workflow.plugins.infra.v1.VPCConfig"). Delegates to -// catalog.ConfigMessageFQN so the package prefix + acronym-preserving -// CamelCase transform are single-sourced — earlier inline code in -// this file produced "workflow.plugin.infra.v1.VpcConfig" (wrong on -// both halves) per spec-reviewer T6 F2 (commit 1ea231fdd). The -// vendored proto at iac/admin/testdata/infra.proto is authoritative -// for both the package name and the message-name acronym table. -func typeNameToConfigFQN(typeName string) string { - return catalog.ConfigMessageFQN(typeName) -} diff --git a/iac/admin/handler/list_types_fqn_test.go b/iac/admin/handler/list_types_fqn_test.go deleted file mode 100644 index eda850cd..00000000 --- a/iac/admin/handler/list_types_fqn_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package handler_test - -import ( - "context" - "os" - "regexp" - "strings" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" -) - -// TestListResourceTypes_ConfigMessageFQNMatchesVendoredProto is the -// F2 regression guard (spec-reviewer T6 finding on commit 1ea231fdd). -// It walks every AdminResourceTypeMetadata returned by the handler, -// parses out the short message name from each `config_message_fqn`, -// and asserts (a) the package prefix matches the vendored proto's -// `package workflow.plugins.infra.v1;` declaration AND (b) the -// short name exists as a `message ` line in the vendored -// proto. Without this test, the earlier T6 bug (wrong "plugin" -// singular prefix + missing acronym preservation producing -// "VpcConfig"/"DnsConfig"/etc.) would have shipped silently — -// the original AllFieldsMatchProto test only checked non-emptiness. -func TestListResourceTypes_ConfigMessageFQNMatchesVendoredProto(t *testing.T) { - const vendoredPath = "../catalog/../testdata/infra.proto" - // Walk up to iac/admin/testdata/infra.proto regardless of test - // cwd. The relative path from iac/admin/handler/ is - // ../testdata/infra.proto. - protoBytes, err := os.ReadFile("../testdata/infra.proto") - if err != nil { - t.Fatalf("read vendored proto (%s): %v", vendoredPath, err) - } - src := string(protoBytes) - - // Confirm the vendored proto declares the package we expect — if - // upstream ever renames, this catches it before the FQN parity - // check yields a confusing failure. - pkgRe := regexp.MustCompile(`(?m)^package\s+([A-Za-z0-9_.]+)\s*;`) - pkgMatch := pkgRe.FindStringSubmatch(src) - if len(pkgMatch) < 2 { - t.Fatal("vendored proto missing `package` declaration") - } - gotPkg := pkgMatch[1] - if gotPkg != catalog.ConfigProtoPackage { - t.Fatalf("vendored proto package = %q but catalog.ConfigProtoPackage = %q — update one to match", gotPkg, catalog.ConfigProtoPackage) - } - - // Collect the message names declared in the vendored proto. - msgRe := regexp.MustCompile(`(?m)^\s*message\s+([A-Za-z0-9_]+Config)\s*\{`) - matches := msgRe.FindAllStringSubmatch(src, -1) - vendoredMessages := map[string]bool{} - for _, m := range matches { - vendoredMessages[m[1]] = true - } - if len(vendoredMessages) == 0 { - t.Fatal("regex found 0 *Config messages in vendored proto") - } - - // Now exercise the handler and validate every emitted FQN. - in := &adminpb.AdminListResourceTypesInput{Evidence: authzOK()} - out, err := handler.ListResourceTypes(context.Background(), catalog.New(), nil, in) - if err != nil { - t.Fatalf("ListResourceTypes: %v", err) - } - if len(out.Types) == 0 { - t.Fatal("no types in output; T7b should have populated 13") - } - wantPrefix := catalog.ConfigProtoPackage + "." - for _, ty := range out.Types { - if ty.ConfigMessageFqn == "" { - t.Errorf("type %q: config_message_fqn empty", ty.Type) - continue - } - if !strings.HasPrefix(ty.ConfigMessageFqn, wantPrefix) { - t.Errorf("type %q: config_message_fqn = %q, want prefix %q", ty.Type, ty.ConfigMessageFqn, wantPrefix) - continue - } - shortName := strings.TrimPrefix(ty.ConfigMessageFqn, wantPrefix) - if !vendoredMessages[shortName] { - t.Errorf("type %q: emitted FQN %q references a *Config message NOT present in vendored proto (got short=%q; available=%v)", - ty.Type, ty.ConfigMessageFqn, shortName, sortedKeys(vendoredMessages)) - } - } -} - -func sortedKeys(m map[string]bool) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - // Don't pull in sort here; just stringify for the error message. - return out -} diff --git a/iac/admin/handler/list_types_test.go b/iac/admin/handler/list_types_test.go deleted file mode 100644 index 065d7969..00000000 --- a/iac/admin/handler/list_types_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package handler_test - -import ( - "context" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" -) - -// TestListResourceTypes_HappyPath verifies the handler returns one -// AdminResourceTypeMetadata per type registered in FieldSpecCatalog. -// Post-T7b the catalog has 13 typed Configs; this test asserts the -// shape rather than the exact count so a future catalog refresh -// doesn't break the assertion. -func TestListResourceTypes_HappyPath(t *testing.T) { - cat := catalog.New() - in := &adminpb.AdminListResourceTypesInput{Evidence: authzOK()} - out, err := handler.ListResourceTypes(context.Background(), cat, nil, in) - if err != nil { - t.Fatalf("ListResourceTypes: %v", err) - } - if out.Error != "" { - t.Errorf("unexpected error: %q", out.Error) - } - if len(out.Types) == 0 { - t.Fatal("got 0 types — catalog should be populated post-T7b") - } - // Each entry must have a type name + at least one field. - for _, ty := range out.Types { - if ty.Type == "" { - t.Errorf("AdminResourceTypeMetadata.type empty: %+v", ty) - } - if len(ty.Fields) == 0 { - t.Errorf("AdminResourceTypeMetadata for %q has zero fields", ty.Type) - } - } -} - -// TestListResourceTypes_DefaultDeny pins the authz contract — same -// shape as ListResources: refusal via Output.error, no resources -// leaked. -func TestListResourceTypes_DefaultDeny(t *testing.T) { - cat := catalog.New() - cases := []struct { - name string - ev *adminpb.AdminAuthzEvidence - }{ - {"nil evidence", nil}, - {"checked=false", &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}}, - {"allowed=false", &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - in := &adminpb.AdminListResourceTypesInput{Evidence: c.ev} - out, _ := handler.ListResourceTypes(context.Background(), cat, nil, in) - if out.Error == "" { - t.Error("expected non-empty Error on default-deny") - } - if len(out.Types) != 0 { - t.Errorf("expected empty Types on auth refusal, got %d", len(out.Types)) - } - }) - } -} - -// TestListResourceTypes_AllFieldsMatchProto checks every FieldSpec in -// the catalog projects into AdminFieldSpec field-for-field so the -// form-builder UI gets full metadata. -func TestListResourceTypes_AllFieldsMatchProto(t *testing.T) { - cat := catalog.New() - in := &adminpb.AdminListResourceTypesInput{Evidence: authzOK()} - out, _ := handler.ListResourceTypes(context.Background(), cat, nil, in) - - var sawNonEmptyKind, sawEnumValues, sawDependsOn bool - for _, ty := range out.Types { - for _, f := range ty.Fields { - if f.Name == "" { - t.Errorf("type %q: field with empty name", ty.Type) - } - if f.Kind == "" { - t.Errorf("type %q field %q: empty kind", ty.Type, f.Name) - } - if f.Kind != "" { - sawNonEmptyKind = true - } - if len(f.EnumValues) > 0 { - sawEnumValues = true - } - if f.DependsOnField != "" { - sawDependsOn = true - } - } - } - if !sawNonEmptyKind { - t.Error("no FieldSpec carried a non-empty Kind — projection dropped data") - } - if !sawEnumValues { - t.Error("no FieldSpec carried EnumValues — projection dropped enum lists") - } - if !sawDependsOn { - t.Error("no FieldSpec carried DependsOnField — projection dropped dependency edges") - } -} diff --git a/iac/admin/handler/plan_resource.go b/iac/admin/handler/plan_resource.go deleted file mode 100644 index fede58e8..00000000 --- a/iac/admin/handler/plan_resource.go +++ /dev/null @@ -1,197 +0,0 @@ -package handler - -import ( - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "sort" - - "github.com/GoCodeAlone/workflow/config" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/iac/jitsubst" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// PlanResource implements the PlanResource RPC: plans the in-process -// desired specs against the current state, returning the plan actions -// and a desired_hash for TOCTOU protection. -// -// Signature deviates from the v1 (ctx, state, providers, fieldCatalog, in) -// shape by adding cfg + desiredSpecs (plan-review I-2): the handler needs -// cfg to compute DesiredStateHash correctly and desiredSpecs to scope the -// plan without coupling the handler to the module's config loading. -// -// The providers map is keyed by module name; the first entry is used for -// planning (single-provider per-route model for v1.1). -// -// Evidence default-deny: if authzError is non-empty, the handler returns -// (output, ErrAuthzDenied). The module layer maps this to HTTP 403 via -// writeMutationResponse — NOT 200; the proto tag-100 pattern applies only -// to non-authz errors (provider/backend failures). -func PlanResource( - ctx context.Context, - store interfaces.IaCStateStore, //nolint:revive // nil ok when no state needed (e.g. fresh deploy) - providers map[string]interfaces.IaCProvider, - cfg *config.WorkflowConfig, //nolint:revive // reserved for wfctlhelpers.DesiredStateHash secret-resolution - desiredSpecs []interfaces.ResourceSpec, - in *adminpb.AdminPlanInput, -) (*adminpb.AdminPlanOutput, error) { - if msg := authzError(in.GetEvidence()); msg != "" { - return &adminpb.AdminPlanOutput{Error: msg}, ErrAuthzDenied - } - if len(providers) == 0 { - return &adminpb.AdminPlanOutput{Error: "plan: no iac.provider registered"}, nil - } - - // Select the first provider (single-provider path for v1.1). - var prov interfaces.IaCProvider - for _, p := range providers { - prov = p - break - } - - // Load current state for hash-input resolution and plan baseline. - var current []interfaces.ResourceState - if store != nil { - var err error - current, err = store.ListResources(ctx) - if err != nil { - return &adminpb.AdminPlanOutput{Error: "plan: list state: " + err.Error()}, nil //nolint:nilerr - } - } - - // Apply app_context / resource_filter scoping. - filtered := filterPlanSpecs(desiredSpecs, in.GetAppContext(), in.GetResourceFilter()) - - // Compute the desired hash before planning (hash is over desired - // inputs, not the plan output — matches the CLI path). - desiredHash := handlerDesiredHash(cfg, filtered, current) - - // Delegate planning to the provider. - plan, err := prov.Plan(ctx, filtered, current) - if err != nil { - return &adminpb.AdminPlanOutput{Error: "plan: " + err.Error()}, nil //nolint:nilerr - } - if plan == nil { - plan = &interfaces.IaCPlan{} - } - - // Stamp hash for TOCTOU. - plan.DesiredHash = desiredHash - - // Serialise plan to JSON for the plan_json opaque payload. - planJSON, err := json.Marshal(plan) - if err != nil { - return &adminpb.AdminPlanOutput{Error: "plan: marshal: " + err.Error()}, nil //nolint:nilerr - } - - // Map plan actions to proto. - actions := make([]*adminpb.AdminPlanAction, 0, len(plan.Actions)) - for i := range plan.Actions { - a := &plan.Actions[i] - actions = append(actions, &adminpb.AdminPlanAction{ - ActionType: a.Action, - ResourceName: a.Resource.Name, - Type: a.Resource.Type, - ChangeSummary: summariseChanges(a.Changes), - }) - } - - return &adminpb.AdminPlanOutput{ - PlanId: fmt.Sprintf("plan-%s", desiredHash[:16]), - DesiredHash: desiredHash, - Actions: actions, - PlanJson: planJSON, - }, nil -} - -// filterPlanSpecs applies the optional app_context and resource_filter -// predicates from the PlanResource input to narrow the desired spec set. -// Both filters are AND-ed when non-empty; an empty filter matches everything. -// app_context is matched against the "app_context" label in spec.Config["labels"], -// following the same convention as stateToSummary. resource_filter is matched -// by resource Name. -func filterPlanSpecs(specs []interfaces.ResourceSpec, appCtx, resourceFilter string) []interfaces.ResourceSpec { - if appCtx == "" && resourceFilter == "" { - return specs - } - out := make([]interfaces.ResourceSpec, 0, len(specs)) - for i := range specs { - s := &specs[i] - if resourceFilter != "" && s.Name != resourceFilter { - continue - } - if appCtx != "" { - labels, _ := s.Config["labels"].(map[string]any) - if ac, _ := labels["app_context"].(string); ac != appCtx { - continue - } - } - out = append(out, *s) - } - return out -} - -// summariseChanges produces a short human-readable summary of the -// field-level changes in a plan action. Returns "" for create/delete -// where no diff is expected. -func summariseChanges(changes []interfaces.FieldChange) string { - if len(changes) == 0 { - return "" - } - return fmt.Sprintf("%d field(s) changed", len(changes)) -} - -// DesiredHash mirrors wfctlhelpers.DesiredStateHash but is defined here -// to avoid an import cycle (iac/wfctlhelpers → module → iac/admin/handler). -// Exported so iac/wfctlhelpers/desired_hash_test.go can assert both -// implementations produce identical digests for the same inputs, preventing -// silent copy-drift. cfg is reserved for future secret-resolution parity. -func DesiredHash(cfg *config.WorkflowConfig, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) string { - return handlerDesiredHash(cfg, desired, current) -} - -// handlerDesiredHash is the internal implementation; callers within the -// handler package use this directly; external callers use DesiredHash. -func handlerDesiredHash(_ *config.WorkflowConfig, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) string { - // Build syncedOutputs from current state (module name → outputs + "id"). - syncedOutputs := make(map[string]map[string]any, len(current)) - for i := range current { - s := ¤t[i] - m := make(map[string]any, len(s.Outputs)+1) - for k, v := range s.Outputs { - m[k] = v - } - if s.ProviderID != "" { - m["id"] = s.ProviderID - } - syncedOutputs[s.Name] = m - } - - // Resolve only ${MODULE.field} refs from current state. - // Use a no-op env lookup so ${ENV_VAR} and ${secret.*} placeholders - // are preserved verbatim — they must hash identically at plan time - // and apply time (env drift tracked via InputSnapshot, not the hash). - noopEnv := func(string) (string, bool) { return "", false } - resolved := make([]interfaces.ResourceSpec, 0, len(desired)) - for _, spec := range desired { - r, _, err := jitsubst.TryResolveSpec(spec, nil, syncedOutputs, noopEnv) - if err != nil { - r = spec - } - resolved = append(resolved, r) - } - - // Sort by name for stable ordering. - sort.Slice(resolved, func(i, j int) bool { - return resolved[i].Name < resolved[j].Name - }) - - data, err := json.Marshal(resolved) - if err != nil { - return "" // error sentinel — callers treat "" as "hash unavailable" - } - sum := sha256.Sum256(data) - return fmt.Sprintf("%x", sum) -} diff --git a/iac/admin/handler/plan_resource_test.go b/iac/admin/handler/plan_resource_test.go deleted file mode 100644 index 090c7b17..00000000 --- a/iac/admin/handler/plan_resource_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package handler_test - -import ( - "context" - "errors" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" -) - -// TestPlanResource_DefaultDeny asserts that evidence with checked=false -// returns a non-empty error and no plan payload. -func TestPlanResource_DefaultDeny(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc"}, - } - in := &adminpb.AdminPlanInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false}, - } - out, err := handler.PlanResource(context.Background(), nil, providers, nil, desired, in) - if !errors.Is(err, handler.ErrAuthzDenied) { - t.Fatalf("PlanResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError()) - } - if out.Error == "" { - t.Error("PlanResource with evidence.checked=false should return non-empty error") - } - if out.PlanId != "" || len(out.Actions) > 0 { - t.Error("PlanResource with denial should return no plan payload") - } -} - -// TestPlanResource_ReturnsActions asserts that a valid evidence returns -// a plan_id, non-empty desired_hash, and at least one action. -func TestPlanResource_ReturnsActions(t *testing.T) { - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, - } - in := &adminpb.AdminPlanInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - out, err := handler.PlanResource(context.Background(), nil, providers, nil, desired, in) - if err != nil { - t.Fatalf("PlanResource: unexpected error: %v", err) - } - if out.Error != "" { - t.Fatalf("PlanResource: unexpected error in output: %s", out.Error) - } - if out.PlanId == "" { - t.Error("PlanResource: plan_id should be non-empty") - } - if out.DesiredHash == "" { - t.Error("PlanResource: desired_hash should be non-empty") - } - if len(out.Actions) == 0 { - t.Error("PlanResource: actions list should be non-empty for 1-spec desired set with no current state") - } - if out.Actions[0].ActionType != "create" { - t.Errorf("PlanResource: expected action_type 'create', got %q", out.Actions[0].ActionType) - } -} - -// TestPlanResource_NoProvidersError asserts that calling PlanResource with -// an empty providers map returns an error indicating no provider is available. -func TestPlanResource_NoProvidersError(t *testing.T) { - desired := []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc"}, - } - in := &adminpb.AdminPlanInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - out, err := handler.PlanResource(context.Background(), nil, nil, nil, desired, in) - if err != nil { - t.Fatalf("PlanResource: unexpected Go error: %v", err) - } - if out.Error == "" { - t.Error("PlanResource with no providers should return non-empty error") - } -} - -// TestPlanResource_WithCurrentState asserts that existing state is reflected -// in the plan (create vs update). -func TestPlanResource_WithCurrentState(t *testing.T) { - store := &fakeStateStore{ - resources: []interfaces.ResourceState{ - {Name: "vpc1", Type: "infra.vpc", ProviderID: "do-vpc-111"}, - }, - } - prov := &planningProvider{} - providers := map[string]interfaces.IaCProvider{"stub": prov} - desired := []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc"}, // already exists → update - {Name: "db1", Type: "infra.database"}, // new → create - } - in := &adminpb.AdminPlanInput{ - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - out, err := handler.PlanResource(context.Background(), store, providers, nil, desired, in) - if err != nil { - t.Fatalf("PlanResource: unexpected error: %v", err) - } - if out.Error != "" { - t.Fatalf("PlanResource: output error: %s", out.Error) - } - if len(out.Actions) != 2 { - t.Fatalf("expected 2 actions (1 update + 1 create), got %d", len(out.Actions)) - } - // Pin the create/update distinction per spec (FAIL-16 fix). - actionByName := map[string]string{} - for _, a := range out.Actions { - actionByName[a.ResourceName] = a.ActionType - } - if actionByName["vpc1"] != "update" { - t.Errorf("vpc1 (existing) should be 'update', got %q", actionByName["vpc1"]) - } - if actionByName["db1"] != "create" { - t.Errorf("db1 (new) should be 'create', got %q", actionByName["db1"]) - } -} diff --git a/iac/admin/proto/infra_admin.pb.go b/iac/admin/proto/infra_admin.pb.go deleted file mode 100644 index 41faadbf..00000000 --- a/iac/admin/proto/infra_admin.pb.go +++ /dev/null @@ -1,2480 +0,0 @@ -// infra_admin.proto — typed contracts for the host-side infra.admin -// workflow module's HTTP surface. Sole serialization shape is -// protojson over HTTP. v1 is read-only — mutations remain in -// `wfctl infra apply/destroy/drift`. -// -// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Strict Proto Contracts -// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 4) -// -// Hard invariants: -// - Every typed input carries AdminAuthzEvidence. Read endpoints -// require evidence.authz_checked && evidence.authz_allowed. -// Default-deny semantics. -// - Free-form per-resource AppliedConfig / Outputs payloads cross -// the wire as bytes _json, JSON-encoded by the handler. -// The handler owns the serialization shape; the proto carries -// opaque bytes (same pattern as plugin/external/proto/iac.proto). -// - error field uses tag 100 (uniform discriminator across outputs -// so generic decoders can sniff for a non-empty error before -// consuming the typed payload). -// - The HTTP audit-tail endpoint (GET /api/infra-admin/audit) is -// NOT exposed as a gRPC RPC — it streams ndjson of -// AdminAuditEntry directly per design doc §Access logging. The -// 5 declared RPCs cover every typed read endpoint. - -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc v7.35.0 -// source: iac/admin/proto/infra_admin.proto - -package adminpb - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// AdminAuthzEvidence carries the host-side authz outcome to handler -// library functions. The handler MUST refuse to serve when -// authz_checked is false (caller bypassed admin auth middleware) or -// authz_allowed is false (caller was denied). Subject + granted -// permissions are recorded in the audit log alongside each request. -type AdminAuthzEvidence struct { - state protoimpl.MessageState `protogen:"open.v1"` - AuthzChecked bool `protobuf:"varint,1,opt,name=authz_checked,json=authzChecked,proto3" json:"authz_checked,omitempty"` - AuthzAllowed bool `protobuf:"varint,2,opt,name=authz_allowed,json=authzAllowed,proto3" json:"authz_allowed,omitempty"` - Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` - GrantedPermissions []string `protobuf:"bytes,4,rep,name=granted_permissions,json=grantedPermissions,proto3" json:"granted_permissions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminAuthzEvidence) Reset() { - *x = AdminAuthzEvidence{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminAuthzEvidence) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminAuthzEvidence) ProtoMessage() {} - -func (x *AdminAuthzEvidence) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminAuthzEvidence.ProtoReflect.Descriptor instead. -func (*AdminAuthzEvidence) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{0} -} - -func (x *AdminAuthzEvidence) GetAuthzChecked() bool { - if x != nil { - return x.AuthzChecked - } - return false -} - -func (x *AdminAuthzEvidence) GetAuthzAllowed() bool { - if x != nil { - return x.AuthzAllowed - } - return false -} - -func (x *AdminAuthzEvidence) GetSubject() string { - if x != nil { - return x.Subject - } - return "" -} - -func (x *AdminAuthzEvidence) GetGrantedPermissions() []string { - if x != nil { - return x.GrantedPermissions - } - return nil -} - -// AdminResourceSummary is the table-row shape for the resources list -// page. provider_module is the host YAML `name:` of the iac.provider -// module that owns this resource; provider_type is the underlying -// cloud provider string (aws/digitalocean/etc) read from that -// module's config.provider field at module Init time. -type AdminResourceSummary struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` // "infra.vpc" - ProviderModule string `protobuf:"bytes,3,opt,name=provider_module,json=providerModule,proto3" json:"provider_module,omitempty"` // host module name (the YAML `name:`) - ProviderType string `protobuf:"bytes,4,opt,name=provider_type,json=providerType,proto3" json:"provider_type,omitempty"` // from the iac.provider module's config `provider:` field - ProviderId string `protobuf:"bytes,5,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` - UpdatedAtUnix int64 `protobuf:"varint,7,opt,name=updated_at_unix,json=updatedAtUnix,proto3" json:"updated_at_unix,omitempty"` - Dependencies []string `protobuf:"bytes,8,rep,name=dependencies,proto3" json:"dependencies,omitempty"` - AppContext string `protobuf:"bytes,9,opt,name=app_context,json=appContext,proto3" json:"app_context,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminResourceSummary) Reset() { - *x = AdminResourceSummary{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminResourceSummary) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminResourceSummary) ProtoMessage() {} - -func (x *AdminResourceSummary) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminResourceSummary.ProtoReflect.Descriptor instead. -func (*AdminResourceSummary) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{1} -} - -func (x *AdminResourceSummary) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *AdminResourceSummary) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *AdminResourceSummary) GetProviderModule() string { - if x != nil { - return x.ProviderModule - } - return "" -} - -func (x *AdminResourceSummary) GetProviderType() string { - if x != nil { - return x.ProviderType - } - return "" -} - -func (x *AdminResourceSummary) GetProviderId() string { - if x != nil { - return x.ProviderId - } - return "" -} - -func (x *AdminResourceSummary) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -func (x *AdminResourceSummary) GetUpdatedAtUnix() int64 { - if x != nil { - return x.UpdatedAtUnix - } - return 0 -} - -func (x *AdminResourceSummary) GetDependencies() []string { - if x != nil { - return x.Dependencies - } - return nil -} - -func (x *AdminResourceSummary) GetAppContext() string { - if x != nil { - return x.AppContext - } - return "" -} - -// AdminResourceDetail extends the summary with the full per-resource -// state payload. applied_config_json + outputs_json are JSON-encoded -// by the handler; sensitive_outputs_redacted lists output keys whose -// values were stripped from outputs_json before serialization (the -// raw key names are safe to surface; the values are not). -type AdminResourceDetail struct { - state protoimpl.MessageState `protogen:"open.v1"` - Summary *AdminResourceSummary `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"` - AppliedConfigJson []byte `protobuf:"bytes,2,opt,name=applied_config_json,json=appliedConfigJson,proto3" json:"applied_config_json,omitempty"` - OutputsJson []byte `protobuf:"bytes,3,opt,name=outputs_json,json=outputsJson,proto3" json:"outputs_json,omitempty"` - ConfigHash string `protobuf:"bytes,4,opt,name=config_hash,json=configHash,proto3" json:"config_hash,omitempty"` - LastDriftCheckUnix int64 `protobuf:"varint,5,opt,name=last_drift_check_unix,json=lastDriftCheckUnix,proto3" json:"last_drift_check_unix,omitempty"` - SensitiveOutputsRedacted []string `protobuf:"bytes,6,rep,name=sensitive_outputs_redacted,json=sensitiveOutputsRedacted,proto3" json:"sensitive_outputs_redacted,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminResourceDetail) Reset() { - *x = AdminResourceDetail{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminResourceDetail) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminResourceDetail) ProtoMessage() {} - -func (x *AdminResourceDetail) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminResourceDetail.ProtoReflect.Descriptor instead. -func (*AdminResourceDetail) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{2} -} - -func (x *AdminResourceDetail) GetSummary() *AdminResourceSummary { - if x != nil { - return x.Summary - } - return nil -} - -func (x *AdminResourceDetail) GetAppliedConfigJson() []byte { - if x != nil { - return x.AppliedConfigJson - } - return nil -} - -func (x *AdminResourceDetail) GetOutputsJson() []byte { - if x != nil { - return x.OutputsJson - } - return nil -} - -func (x *AdminResourceDetail) GetConfigHash() string { - if x != nil { - return x.ConfigHash - } - return "" -} - -func (x *AdminResourceDetail) GetLastDriftCheckUnix() int64 { - if x != nil { - return x.LastDriftCheckUnix - } - return 0 -} - -func (x *AdminResourceDetail) GetSensitiveOutputsRedacted() []string { - if x != nil { - return x.SensitiveOutputsRedacted - } - return nil -} - -// AdminListResourcesInput is the request shape for the ListResources -// RPC. Filters narrow the returned set; evidence carries the host-side -// authz outcome (default-deny semantics). -type AdminListResourcesInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - TypeFilter string `protobuf:"bytes,1,opt,name=type_filter,json=typeFilter,proto3" json:"type_filter,omitempty"` - ProviderFilter string `protobuf:"bytes,2,opt,name=provider_filter,json=providerFilter,proto3" json:"provider_filter,omitempty"` - AppContextFilter string `protobuf:"bytes,3,opt,name=app_context_filter,json=appContextFilter,proto3" json:"app_context_filter,omitempty"` - EnvName string `protobuf:"bytes,4,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,5,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminListResourcesInput) Reset() { - *x = AdminListResourcesInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminListResourcesInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminListResourcesInput) ProtoMessage() {} - -func (x *AdminListResourcesInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminListResourcesInput.ProtoReflect.Descriptor instead. -func (*AdminListResourcesInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{3} -} - -func (x *AdminListResourcesInput) GetTypeFilter() string { - if x != nil { - return x.TypeFilter - } - return "" -} - -func (x *AdminListResourcesInput) GetProviderFilter() string { - if x != nil { - return x.ProviderFilter - } - return "" -} - -func (x *AdminListResourcesInput) GetAppContextFilter() string { - if x != nil { - return x.AppContextFilter - } - return "" -} - -func (x *AdminListResourcesInput) GetEnvName() string { - if x != nil { - return x.EnvName - } - return "" -} - -func (x *AdminListResourcesInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminListResourcesOutput is the response shape for the ListResources -// RPC. error is set when the handler refused (e.g. authz denied); when -// non-empty, consumers MUST ignore the typed payload. -type AdminListResourcesOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Resources []*AdminResourceSummary `protobuf:"bytes,1,rep,name=resources,proto3" json:"resources,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminListResourcesOutput) Reset() { - *x = AdminListResourcesOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminListResourcesOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminListResourcesOutput) ProtoMessage() {} - -func (x *AdminListResourcesOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminListResourcesOutput.ProtoReflect.Descriptor instead. -func (*AdminListResourcesOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{4} -} - -func (x *AdminListResourcesOutput) GetResources() []*AdminResourceSummary { - if x != nil { - return x.Resources - } - return nil -} - -func (x *AdminListResourcesOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminGetResourceInput is the request shape for the GetResource RPC. -// name is the resource's host-side identity (the YAML `name:`); the -// returned detail includes the full applied config + outputs. -type AdminGetResourceInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - EnvName string `protobuf:"bytes,2,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,3,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminGetResourceInput) Reset() { - *x = AdminGetResourceInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminGetResourceInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminGetResourceInput) ProtoMessage() {} - -func (x *AdminGetResourceInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminGetResourceInput.ProtoReflect.Descriptor instead. -func (*AdminGetResourceInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{5} -} - -func (x *AdminGetResourceInput) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *AdminGetResourceInput) GetEnvName() string { - if x != nil { - return x.EnvName - } - return "" -} - -func (x *AdminGetResourceInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminGetResourceOutput is the response shape for the GetResource RPC. -// Carries AdminResourceDetail when found; error when the resource is -// missing or authz denied. -type AdminGetResourceOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Resource *AdminResourceDetail `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminGetResourceOutput) Reset() { - *x = AdminGetResourceOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminGetResourceOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminGetResourceOutput) ProtoMessage() {} - -func (x *AdminGetResourceOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminGetResourceOutput.ProtoReflect.Descriptor instead. -func (*AdminGetResourceOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{6} -} - -func (x *AdminGetResourceOutput) GetResource() *AdminResourceDetail { - if x != nil { - return x.Resource - } - return nil -} - -func (x *AdminGetResourceOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminFieldSpec describes one field of a typed infra.* Config so the -// new-resource form-builder can render the right input control. -// - kind: one of {enum, enum_dynamic, string, number, bool, -// array_string, array_object, object} — see catalog/fields.go. -// - enum_source (only for kind == enum_dynamic): "providers" | -// "regions" | "sizes" | "engines" — the form fetches dynamic -// options at render time. -// - depends_on_field: when set, this field's options are filtered -// by the value the user picked for depends_on_field (e.g. region -// options depend on provider). -// - element_kind: only meaningful for array_* kinds. -// - sensitive: when true, the form renders a masked input AND the -// value is excluded from any rendered preview. -type AdminFieldSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` - Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` - Required bool `protobuf:"varint,4,opt,name=required,proto3" json:"required,omitempty"` - EnumValues []string `protobuf:"bytes,5,rep,name=enum_values,json=enumValues,proto3" json:"enum_values,omitempty"` - EnumSource string `protobuf:"bytes,6,opt,name=enum_source,json=enumSource,proto3" json:"enum_source,omitempty"` - Description string `protobuf:"bytes,7,opt,name=description,proto3" json:"description,omitempty"` - DefaultValue string `protobuf:"bytes,8,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` - Sensitive bool `protobuf:"varint,9,opt,name=sensitive,proto3" json:"sensitive,omitempty"` - ElementKind string `protobuf:"bytes,10,opt,name=element_kind,json=elementKind,proto3" json:"element_kind,omitempty"` - MinCount int32 `protobuf:"varint,11,opt,name=min_count,json=minCount,proto3" json:"min_count,omitempty"` - MaxCount int32 `protobuf:"varint,12,opt,name=max_count,json=maxCount,proto3" json:"max_count,omitempty"` - DependsOnField string `protobuf:"bytes,13,opt,name=depends_on_field,json=dependsOnField,proto3" json:"depends_on_field,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminFieldSpec) Reset() { - *x = AdminFieldSpec{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminFieldSpec) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminFieldSpec) ProtoMessage() {} - -func (x *AdminFieldSpec) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminFieldSpec.ProtoReflect.Descriptor instead. -func (*AdminFieldSpec) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{7} -} - -func (x *AdminFieldSpec) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *AdminFieldSpec) GetLabel() string { - if x != nil { - return x.Label - } - return "" -} - -func (x *AdminFieldSpec) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -func (x *AdminFieldSpec) GetRequired() bool { - if x != nil { - return x.Required - } - return false -} - -func (x *AdminFieldSpec) GetEnumValues() []string { - if x != nil { - return x.EnumValues - } - return nil -} - -func (x *AdminFieldSpec) GetEnumSource() string { - if x != nil { - return x.EnumSource - } - return "" -} - -func (x *AdminFieldSpec) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *AdminFieldSpec) GetDefaultValue() string { - if x != nil { - return x.DefaultValue - } - return "" -} - -func (x *AdminFieldSpec) GetSensitive() bool { - if x != nil { - return x.Sensitive - } - return false -} - -func (x *AdminFieldSpec) GetElementKind() string { - if x != nil { - return x.ElementKind - } - return "" -} - -func (x *AdminFieldSpec) GetMinCount() int32 { - if x != nil { - return x.MinCount - } - return 0 -} - -func (x *AdminFieldSpec) GetMaxCount() int32 { - if x != nil { - return x.MaxCount - } - return 0 -} - -func (x *AdminFieldSpec) GetDependsOnField() string { - if x != nil { - return x.DependsOnField - } - return "" -} - -// AdminResourceTypeMetadata is the form-builder's view of one -// infra.* Config message. config_message_fqn is the fully-qualified -// proto message name in workflow-plugin-infra/internal/contracts/ -// infra.proto (e.g. "workflow.plugins.infra.v1.VPCConfig" — note -// the plural "plugins" matching the vendored proto's package -// declaration; earlier doc-example used "plugin" singular which -// didn't match the wire, see spec-reviewer T6 F2 on commit 1ea231fdd) -// so callers can correlate against the vendored proto descriptor. -type AdminResourceTypeMetadata struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` - ConfigMessageFqn string `protobuf:"bytes,2,opt,name=config_message_fqn,json=configMessageFqn,proto3" json:"config_message_fqn,omitempty"` - Fields []*AdminFieldSpec `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"` - SupportedProviders []string `protobuf:"bytes,4,rep,name=supported_providers,json=supportedProviders,proto3" json:"supported_providers,omitempty"` - Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminResourceTypeMetadata) Reset() { - *x = AdminResourceTypeMetadata{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminResourceTypeMetadata) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminResourceTypeMetadata) ProtoMessage() {} - -func (x *AdminResourceTypeMetadata) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminResourceTypeMetadata.ProtoReflect.Descriptor instead. -func (*AdminResourceTypeMetadata) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{8} -} - -func (x *AdminResourceTypeMetadata) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *AdminResourceTypeMetadata) GetConfigMessageFqn() string { - if x != nil { - return x.ConfigMessageFqn - } - return "" -} - -func (x *AdminResourceTypeMetadata) GetFields() []*AdminFieldSpec { - if x != nil { - return x.Fields - } - return nil -} - -func (x *AdminResourceTypeMetadata) GetSupportedProviders() []string { - if x != nil { - return x.SupportedProviders - } - return nil -} - -func (x *AdminResourceTypeMetadata) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -// AdminListResourceTypesInput is the request shape for the -// ListResourceTypes RPC. provider_filter narrows the returned types to -// those a given provider supports. -type AdminListResourceTypesInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProviderFilter string `protobuf:"bytes,1,opt,name=provider_filter,json=providerFilter,proto3" json:"provider_filter,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,2,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminListResourceTypesInput) Reset() { - *x = AdminListResourceTypesInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminListResourceTypesInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminListResourceTypesInput) ProtoMessage() {} - -func (x *AdminListResourceTypesInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminListResourceTypesInput.ProtoReflect.Descriptor instead. -func (*AdminListResourceTypesInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{9} -} - -func (x *AdminListResourceTypesInput) GetProviderFilter() string { - if x != nil { - return x.ProviderFilter - } - return "" -} - -func (x *AdminListResourceTypesInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminListResourceTypesOutput is the response shape for the -// ListResourceTypes RPC. types is the form-builder's view of every -// registered infra.* Config (one entry per FieldSpecCatalog type). -type AdminListResourceTypesOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Types []*AdminResourceTypeMetadata `protobuf:"bytes,1,rep,name=types,proto3" json:"types,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminListResourceTypesOutput) Reset() { - *x = AdminListResourceTypesOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminListResourceTypesOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminListResourceTypesOutput) ProtoMessage() {} - -func (x *AdminListResourceTypesOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminListResourceTypesOutput.ProtoReflect.Descriptor instead. -func (*AdminListResourceTypesOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{10} -} - -func (x *AdminListResourceTypesOutput) GetTypes() []*AdminResourceTypeMetadata { - if x != nil { - return x.Types - } - return nil -} - -func (x *AdminListResourceTypesOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminProviderSummary is the ListProviders response row. supported_regions -// comes from IaCProviderRegionLister when the provider advertises it and the -// call succeeds; otherwise it falls back to the host-side catalog -// (regions.go). supported_types / supported_engines remain host catalogued. -// regions_source is "provider-lister" or "local-catalog". -type AdminProviderSummary struct { - state protoimpl.MessageState `protogen:"open.v1"` - ModuleName string `protobuf:"bytes,1,opt,name=module_name,json=moduleName,proto3" json:"module_name,omitempty"` - ProviderType string `protobuf:"bytes,2,opt,name=provider_type,json=providerType,proto3" json:"provider_type,omitempty"` - Capabilities []string `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty"` - SupportedRegions []string `protobuf:"bytes,4,rep,name=supported_regions,json=supportedRegions,proto3" json:"supported_regions,omitempty"` - SupportedTypes []string `protobuf:"bytes,5,rep,name=supported_types,json=supportedTypes,proto3" json:"supported_types,omitempty"` - SupportedEngines []string `protobuf:"bytes,6,rep,name=supported_engines,json=supportedEngines,proto3" json:"supported_engines,omitempty"` - RegionsSource string `protobuf:"bytes,7,opt,name=regions_source,json=regionsSource,proto3" json:"regions_source,omitempty"` // "provider-lister" or "local-catalog" - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminProviderSummary) Reset() { - *x = AdminProviderSummary{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminProviderSummary) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminProviderSummary) ProtoMessage() {} - -func (x *AdminProviderSummary) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminProviderSummary.ProtoReflect.Descriptor instead. -func (*AdminProviderSummary) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{11} -} - -func (x *AdminProviderSummary) GetModuleName() string { - if x != nil { - return x.ModuleName - } - return "" -} - -func (x *AdminProviderSummary) GetProviderType() string { - if x != nil { - return x.ProviderType - } - return "" -} - -func (x *AdminProviderSummary) GetCapabilities() []string { - if x != nil { - return x.Capabilities - } - return nil -} - -func (x *AdminProviderSummary) GetSupportedRegions() []string { - if x != nil { - return x.SupportedRegions - } - return nil -} - -func (x *AdminProviderSummary) GetSupportedTypes() []string { - if x != nil { - return x.SupportedTypes - } - return nil -} - -func (x *AdminProviderSummary) GetSupportedEngines() []string { - if x != nil { - return x.SupportedEngines - } - return nil -} - -func (x *AdminProviderSummary) GetRegionsSource() string { - if x != nil { - return x.RegionsSource - } - return "" -} - -// AdminListProvidersInput is the request shape for the ListProviders -// RPC. env_name selects the per-environment overlay. -type AdminListProvidersInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - EnvName string `protobuf:"bytes,1,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,2,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminListProvidersInput) Reset() { - *x = AdminListProvidersInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminListProvidersInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminListProvidersInput) ProtoMessage() {} - -func (x *AdminListProvidersInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminListProvidersInput.ProtoReflect.Descriptor instead. -func (*AdminListProvidersInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{12} -} - -func (x *AdminListProvidersInput) GetEnvName() string { - if x != nil { - return x.EnvName - } - return "" -} - -func (x *AdminListProvidersInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminListProvidersOutput is the response shape for the ListProviders -// RPC. One AdminProviderSummary per iac.provider module declared in the -// host's WorkflowConfig. -type AdminListProvidersOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Providers []*AdminProviderSummary `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminListProvidersOutput) Reset() { - *x = AdminListProvidersOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminListProvidersOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminListProvidersOutput) ProtoMessage() {} - -func (x *AdminListProvidersOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminListProvidersOutput.ProtoReflect.Descriptor instead. -func (*AdminListProvidersOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{13} -} - -func (x *AdminListProvidersOutput) GetProviders() []*AdminProviderSummary { - if x != nil { - return x.Providers - } - return nil -} - -func (x *AdminListProvidersOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminGenerateConfigInput carries the form-builder submission. -// field_values is keyed by AdminFieldSpec.name; values are always -// string-encoded (the catalog's kind metadata is the authority for -// coercion to proto types). resource_name is the host YAML `name:` -// the user picked; provider_module references one of the -// AdminProviderSummary.module_name values returned by ListProviders. -type AdminGenerateConfigInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - ResourceType string `protobuf:"bytes,1,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` - ResourceName string `protobuf:"bytes,2,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` - ProviderModule string `protobuf:"bytes,3,opt,name=provider_module,json=providerModule,proto3" json:"provider_module,omitempty"` - // field_values carries the form-builder submission keyed by - // AdminFieldSpec.name. Single-valued fields (string/enum/bool/ - // number) are encoded as their literal string. Array-shaped - // fields (kind ∈ {array_string, array_object, array_number, - // array_enum_dynamic}) are JSON-encoded — e.g. - // - // field_values["ingress"] = "[\"rule a\", \"rule b, c\"]" - // - // — so values containing commas, quotes, or other delimiters - // survive the wire losslessly. The server decodes via - // json.Unmarshal in iac/admin/handler/generate_config.go. - // - // Cross-task contract locked 2026-05-27 between T6 (server) + - // T10-T12 (form-builder JS). Defensive fallback in the handler - // wraps a non-JSON literal into a one-element array so a - // malformed UI submission doesn't crash the server. - FieldValues map[string]string `protobuf:"bytes,4,rep,name=field_values,json=fieldValues,proto3" json:"field_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,5,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminGenerateConfigInput) Reset() { - *x = AdminGenerateConfigInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminGenerateConfigInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminGenerateConfigInput) ProtoMessage() {} - -func (x *AdminGenerateConfigInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminGenerateConfigInput.ProtoReflect.Descriptor instead. -func (*AdminGenerateConfigInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{14} -} - -func (x *AdminGenerateConfigInput) GetResourceType() string { - if x != nil { - return x.ResourceType - } - return "" -} - -func (x *AdminGenerateConfigInput) GetResourceName() string { - if x != nil { - return x.ResourceName - } - return "" -} - -func (x *AdminGenerateConfigInput) GetProviderModule() string { - if x != nil { - return x.ProviderModule - } - return "" -} - -func (x *AdminGenerateConfigInput) GetFieldValues() map[string]string { - if x != nil { - return x.FieldValues - } - return nil -} - -func (x *AdminGenerateConfigInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminGenerateConfigOutput is the response shape for the -// GenerateConfig RPC. yaml_snippet is the typed-coerced YAML the form -// produced; validation_errors carries any per-field validation -// failures the catalog reported (form remains submittable on -// validation_errors — `error` is reserved for handler-level failures -// like authz denial). -type AdminGenerateConfigOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - YamlSnippet string `protobuf:"bytes,1,opt,name=yaml_snippet,json=yamlSnippet,proto3" json:"yaml_snippet,omitempty"` - ValidationErrors []string `protobuf:"bytes,2,rep,name=validation_errors,json=validationErrors,proto3" json:"validation_errors,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminGenerateConfigOutput) Reset() { - *x = AdminGenerateConfigOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminGenerateConfigOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminGenerateConfigOutput) ProtoMessage() {} - -func (x *AdminGenerateConfigOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminGenerateConfigOutput.ProtoReflect.Descriptor instead. -func (*AdminGenerateConfigOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{15} -} - -func (x *AdminGenerateConfigOutput) GetYamlSnippet() string { - if x != nil { - return x.YamlSnippet - } - return "" -} - -func (x *AdminGenerateConfigOutput) GetValidationErrors() []string { - if x != nil { - return x.ValidationErrors - } - return nil -} - -func (x *AdminGenerateConfigOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminAuditEntry is the line shape for the audit log (one entry per -// non-noop admin action) AND the streaming response shape of the -// HTTP audit-tail endpoint (GET /api/infra-admin/audit). schema_version -// starts at 1; bumps are additive (new fields) until a breaking -// change forces a major. -type AdminAuditEntry struct { - state protoimpl.MessageState `protogen:"open.v1"` - SchemaVersion int32 `protobuf:"varint,1,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` - TsUnix int64 `protobuf:"varint,2,opt,name=ts_unix,json=tsUnix,proto3" json:"ts_unix,omitempty"` - Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` - Action string `protobuf:"bytes,4,opt,name=action,proto3" json:"action,omitempty"` - Targets []string `protobuf:"bytes,5,rep,name=targets,proto3" json:"targets,omitempty"` - Result string `protobuf:"bytes,6,opt,name=result,proto3" json:"result,omitempty"` - AppContext string `protobuf:"bytes,7,opt,name=app_context,json=appContext,proto3" json:"app_context,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminAuditEntry) Reset() { - *x = AdminAuditEntry{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminAuditEntry) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminAuditEntry) ProtoMessage() {} - -func (x *AdminAuditEntry) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminAuditEntry.ProtoReflect.Descriptor instead. -func (*AdminAuditEntry) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{16} -} - -func (x *AdminAuditEntry) GetSchemaVersion() int32 { - if x != nil { - return x.SchemaVersion - } - return 0 -} - -func (x *AdminAuditEntry) GetTsUnix() int64 { - if x != nil { - return x.TsUnix - } - return 0 -} - -func (x *AdminAuditEntry) GetSubject() string { - if x != nil { - return x.Subject - } - return "" -} - -func (x *AdminAuditEntry) GetAction() string { - if x != nil { - return x.Action - } - return "" -} - -func (x *AdminAuditEntry) GetTargets() []string { - if x != nil { - return x.Targets - } - return nil -} - -func (x *AdminAuditEntry) GetResult() string { - if x != nil { - return x.Result - } - return "" -} - -func (x *AdminAuditEntry) GetAppContext() string { - if x != nil { - return x.AppContext - } - return "" -} - -// AdminPlanInput is the request shape for the PlanResource RPC. -// Plans only the in-process config (no client-proposed desired state — -// see NEW-M-3). evidence is audit-only; server-side authz.Enforce -// gates the handler (ADR-0007). -type AdminPlanInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - AppContext string `protobuf:"bytes,1,opt,name=app_context,json=appContext,proto3" json:"app_context,omitempty"` - ResourceFilter string `protobuf:"bytes,2,opt,name=resource_filter,json=resourceFilter,proto3" json:"resource_filter,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,3,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminPlanInput) Reset() { - *x = AdminPlanInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminPlanInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminPlanInput) ProtoMessage() {} - -func (x *AdminPlanInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminPlanInput.ProtoReflect.Descriptor instead. -func (*AdminPlanInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{17} -} - -func (x *AdminPlanInput) GetAppContext() string { - if x != nil { - return x.AppContext - } - return "" -} - -func (x *AdminPlanInput) GetResourceFilter() string { - if x != nil { - return x.ResourceFilter - } - return "" -} - -func (x *AdminPlanInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminPlanAction is one proposed change in the plan (create/update/ -// replace/delete). change_summary is a human-readable diff summary. -type AdminPlanAction struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionType string `protobuf:"bytes,1,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"` - ResourceName string `protobuf:"bytes,2,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` - Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` - ChangeSummary string `protobuf:"bytes,4,opt,name=change_summary,json=changeSummary,proto3" json:"change_summary,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminPlanAction) Reset() { - *x = AdminPlanAction{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminPlanAction) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminPlanAction) ProtoMessage() {} - -func (x *AdminPlanAction) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminPlanAction.ProtoReflect.Descriptor instead. -func (*AdminPlanAction) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{18} -} - -func (x *AdminPlanAction) GetActionType() string { - if x != nil { - return x.ActionType - } - return "" -} - -func (x *AdminPlanAction) GetResourceName() string { - if x != nil { - return x.ResourceName - } - return "" -} - -func (x *AdminPlanAction) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *AdminPlanAction) GetChangeSummary() string { - if x != nil { - return x.ChangeSummary - } - return "" -} - -// AdminPlanOutput is the response shape for the PlanResource RPC. -// plan_id is opaque; desired_hash is the SHA-256 of the resolved -// desired-state (same input as wfctlhelpers.DesiredStateHash) — the -// client MUST echo it in AdminApplyInput for TOCTOU protection. -// plan_json carries the full plan payload as opaque bytes. -// error tag-100: non-empty → consumer ignores typed payload. -type AdminPlanOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - PlanId string `protobuf:"bytes,1,opt,name=plan_id,json=planId,proto3" json:"plan_id,omitempty"` - DesiredHash string `protobuf:"bytes,2,opt,name=desired_hash,json=desiredHash,proto3" json:"desired_hash,omitempty"` - Actions []*AdminPlanAction `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"` - PlanJson []byte `protobuf:"bytes,4,opt,name=plan_json,json=planJson,proto3" json:"plan_json,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminPlanOutput) Reset() { - *x = AdminPlanOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminPlanOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminPlanOutput) ProtoMessage() {} - -func (x *AdminPlanOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminPlanOutput.ProtoReflect.Descriptor instead. -func (*AdminPlanOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{19} -} - -func (x *AdminPlanOutput) GetPlanId() string { - if x != nil { - return x.PlanId - } - return "" -} - -func (x *AdminPlanOutput) GetDesiredHash() string { - if x != nil { - return x.DesiredHash - } - return "" -} - -func (x *AdminPlanOutput) GetActions() []*AdminPlanAction { - if x != nil { - return x.Actions - } - return nil -} - -func (x *AdminPlanOutput) GetPlanJson() []byte { - if x != nil { - return x.PlanJson - } - return nil -} - -func (x *AdminPlanOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminApplyInput is the request shape for the ApplyResource RPC. -// plan_id + desired_hash MUST match the values returned by a prior -// PlanResource call (TOCTOU protection per ADR-0008). allow_replace -// is the explicit opt-in list for replace actions; the handler calls -// ValidateAllowReplaceProtected against it. evidence is audit-only. -type AdminApplyInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - PlanId string `protobuf:"bytes,1,opt,name=plan_id,json=planId,proto3" json:"plan_id,omitempty"` - DesiredHash string `protobuf:"bytes,2,opt,name=desired_hash,json=desiredHash,proto3" json:"desired_hash,omitempty"` - AllowReplace []string `protobuf:"bytes,3,rep,name=allow_replace,json=allowReplace,proto3" json:"allow_replace,omitempty"` - AppContext string `protobuf:"bytes,4,opt,name=app_context,json=appContext,proto3" json:"app_context,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,5,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminApplyInput) Reset() { - *x = AdminApplyInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminApplyInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminApplyInput) ProtoMessage() {} - -func (x *AdminApplyInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminApplyInput.ProtoReflect.Descriptor instead. -func (*AdminApplyInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{20} -} - -func (x *AdminApplyInput) GetPlanId() string { - if x != nil { - return x.PlanId - } - return "" -} - -func (x *AdminApplyInput) GetDesiredHash() string { - if x != nil { - return x.DesiredHash - } - return "" -} - -func (x *AdminApplyInput) GetAllowReplace() []string { - if x != nil { - return x.AllowReplace - } - return nil -} - -func (x *AdminApplyInput) GetAppContext() string { - if x != nil { - return x.AppContext - } - return "" -} - -func (x *AdminApplyInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminApplyOutput is the response shape for the ApplyResource RPC. -// applied carries summaries of successfully applied resources; errors -// carries per-resource failures (provider errors redacted of creds). -// error tag-100: top-level failure (authz / stale-hash / etc). -type AdminApplyOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Applied []*AdminResourceSummary `protobuf:"bytes,1,rep,name=applied,proto3" json:"applied,omitempty"` - Errors []*AdminActionError `protobuf:"bytes,2,rep,name=errors,proto3" json:"errors,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminApplyOutput) Reset() { - *x = AdminApplyOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminApplyOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminApplyOutput) ProtoMessage() {} - -func (x *AdminApplyOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[21] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminApplyOutput.ProtoReflect.Descriptor instead. -func (*AdminApplyOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{21} -} - -func (x *AdminApplyOutput) GetApplied() []*AdminResourceSummary { - if x != nil { - return x.Applied - } - return nil -} - -func (x *AdminApplyOutput) GetErrors() []*AdminActionError { - if x != nil { - return x.Errors - } - return nil -} - -func (x *AdminApplyOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminActionError carries a per-resource error from an apply or -// destroy operation. Provider error messages have credentials -// redacted before serialization. -type AdminActionError struct { - state protoimpl.MessageState `protogen:"open.v1"` - Resource string `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` - Action string `protobuf:"bytes,2,opt,name=action,proto3" json:"action,omitempty"` - Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminActionError) Reset() { - *x = AdminActionError{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminActionError) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminActionError) ProtoMessage() {} - -func (x *AdminActionError) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[22] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminActionError.ProtoReflect.Descriptor instead. -func (*AdminActionError) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{22} -} - -func (x *AdminActionError) GetResource() string { - if x != nil { - return x.Resource - } - return "" -} - -func (x *AdminActionError) GetAction() string { - if x != nil { - return x.Action - } - return "" -} - -func (x *AdminActionError) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminResourceRef identifies a resource by name + type for destroy -// and drift-check operations. -type AdminResourceRef struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminResourceRef) Reset() { - *x = AdminResourceRef{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminResourceRef) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminResourceRef) ProtoMessage() {} - -func (x *AdminResourceRef) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[23] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminResourceRef.ProtoReflect.Descriptor instead. -func (*AdminResourceRef) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{23} -} - -func (x *AdminResourceRef) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *AdminResourceRef) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -// AdminDestroyInput is the request shape for the DestroyResource RPC. -// refs lists the resources to destroy. confirm_hash is the -// desired-state hash the client must echo for TOCTOU protection -// (same semantics as AdminApplyInput.desired_hash). evidence is -// audit-only. -type AdminDestroyInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Refs []*AdminResourceRef `protobuf:"bytes,1,rep,name=refs,proto3" json:"refs,omitempty"` - ConfirmHash string `protobuf:"bytes,2,opt,name=confirm_hash,json=confirmHash,proto3" json:"confirm_hash,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,3,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminDestroyInput) Reset() { - *x = AdminDestroyInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminDestroyInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminDestroyInput) ProtoMessage() {} - -func (x *AdminDestroyInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[24] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminDestroyInput.ProtoReflect.Descriptor instead. -func (*AdminDestroyInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{24} -} - -func (x *AdminDestroyInput) GetRefs() []*AdminResourceRef { - if x != nil { - return x.Refs - } - return nil -} - -func (x *AdminDestroyInput) GetConfirmHash() string { - if x != nil { - return x.ConfirmHash - } - return "" -} - -func (x *AdminDestroyInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminDestroyOutput is the response shape for the DestroyResource -// RPC. destroyed lists the names of successfully destroyed resources; -// errors carries per-resource failures. error tag-100: top-level. -type AdminDestroyOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Destroyed []string `protobuf:"bytes,1,rep,name=destroyed,proto3" json:"destroyed,omitempty"` - Errors []*AdminActionError `protobuf:"bytes,2,rep,name=errors,proto3" json:"errors,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminDestroyOutput) Reset() { - *x = AdminDestroyOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminDestroyOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminDestroyOutput) ProtoMessage() {} - -func (x *AdminDestroyOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[25] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminDestroyOutput.ProtoReflect.Descriptor instead. -func (*AdminDestroyOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{25} -} - -func (x *AdminDestroyOutput) GetDestroyed() []string { - if x != nil { - return x.Destroyed - } - return nil -} - -func (x *AdminDestroyOutput) GetErrors() []*AdminActionError { - if x != nil { - return x.Errors - } - return nil -} - -func (x *AdminDestroyOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AdminDriftInput is the request shape for the DriftCheckResource RPC. -// refs narrows the check to the listed resources; an empty refs list -// checks all resources known to the provider. evidence is audit-only. -type AdminDriftInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Refs []*AdminResourceRef `protobuf:"bytes,1,rep,name=refs,proto3" json:"refs,omitempty"` - Evidence *AdminAuthzEvidence `protobuf:"bytes,2,opt,name=evidence,proto3" json:"evidence,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminDriftInput) Reset() { - *x = AdminDriftInput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminDriftInput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminDriftInput) ProtoMessage() {} - -func (x *AdminDriftInput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[26] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminDriftInput.ProtoReflect.Descriptor instead. -func (*AdminDriftInput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{26} -} - -func (x *AdminDriftInput) GetRefs() []*AdminResourceRef { - if x != nil { - return x.Refs - } - return nil -} - -func (x *AdminDriftInput) GetEvidence() *AdminAuthzEvidence { - if x != nil { - return x.Evidence - } - return nil -} - -// AdminDriftResult is one resource's drift check outcome. drifted is -// true when the live cloud state diverges from desired; class is the -// drift category (e.g. "config", "presence"); fields lists the -// specific diverged field names. -type AdminDriftResult struct { - state protoimpl.MessageState `protogen:"open.v1"` - ResourceName string `protobuf:"bytes,1,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - Drifted bool `protobuf:"varint,3,opt,name=drifted,proto3" json:"drifted,omitempty"` - Class string `protobuf:"bytes,4,opt,name=class,proto3" json:"class,omitempty"` - Fields []string `protobuf:"bytes,5,rep,name=fields,proto3" json:"fields,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminDriftResult) Reset() { - *x = AdminDriftResult{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminDriftResult) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminDriftResult) ProtoMessage() {} - -func (x *AdminDriftResult) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[27] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminDriftResult.ProtoReflect.Descriptor instead. -func (*AdminDriftResult) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{27} -} - -func (x *AdminDriftResult) GetResourceName() string { - if x != nil { - return x.ResourceName - } - return "" -} - -func (x *AdminDriftResult) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *AdminDriftResult) GetDrifted() bool { - if x != nil { - return x.Drifted - } - return false -} - -func (x *AdminDriftResult) GetClass() string { - if x != nil { - return x.Class - } - return "" -} - -func (x *AdminDriftResult) GetFields() []string { - if x != nil { - return x.Fields - } - return nil -} - -// AdminDriftOutput is the response shape for the DriftCheckResource -// RPC. drift carries one entry per checked resource. error tag-100: -// top-level failure (authz / provider unavailable / etc). -type AdminDriftOutput struct { - state protoimpl.MessageState `protogen:"open.v1"` - Drift []*AdminDriftResult `protobuf:"bytes,1,rep,name=drift,proto3" json:"drift,omitempty"` - Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdminDriftOutput) Reset() { - *x = AdminDriftOutput{} - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdminDriftOutput) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdminDriftOutput) ProtoMessage() {} - -func (x *AdminDriftOutput) ProtoReflect() protoreflect.Message { - mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[28] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdminDriftOutput.ProtoReflect.Descriptor instead. -func (*AdminDriftOutput) Descriptor() ([]byte, []int) { - return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{28} -} - -func (x *AdminDriftOutput) GetDrift() []*AdminDriftResult { - if x != nil { - return x.Drift - } - return nil -} - -func (x *AdminDriftOutput) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -var File_iac_admin_proto_infra_admin_proto protoreflect.FileDescriptor - -const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + - "\n" + - "!iac/admin/proto/infra_admin.proto\x12\x0fworkflow.iac.v1\"\xa9\x01\n" + - "\x12AdminAuthzEvidence\x12#\n" + - "\rauthz_checked\x18\x01 \x01(\bR\fauthzChecked\x12#\n" + - "\rauthz_allowed\x18\x02 \x01(\bR\fauthzAllowed\x12\x18\n" + - "\asubject\x18\x03 \x01(\tR\asubject\x12/\n" + - "\x13granted_permissions\x18\x04 \x03(\tR\x12grantedPermissions\"\xb2\x02\n" + - "\x14AdminResourceSummary\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\x12'\n" + - "\x0fprovider_module\x18\x03 \x01(\tR\x0eproviderModule\x12#\n" + - "\rprovider_type\x18\x04 \x01(\tR\fproviderType\x12\x1f\n" + - "\vprovider_id\x18\x05 \x01(\tR\n" + - "providerId\x12\x16\n" + - "\x06status\x18\x06 \x01(\tR\x06status\x12&\n" + - "\x0fupdated_at_unix\x18\a \x01(\x03R\rupdatedAtUnix\x12\"\n" + - "\fdependencies\x18\b \x03(\tR\fdependencies\x12\x1f\n" + - "\vapp_context\x18\t \x01(\tR\n" + - "appContext\"\xbb\x02\n" + - "\x13AdminResourceDetail\x12?\n" + - "\asummary\x18\x01 \x01(\v2%.workflow.iac.v1.AdminResourceSummaryR\asummary\x12.\n" + - "\x13applied_config_json\x18\x02 \x01(\fR\x11appliedConfigJson\x12!\n" + - "\foutputs_json\x18\x03 \x01(\fR\voutputsJson\x12\x1f\n" + - "\vconfig_hash\x18\x04 \x01(\tR\n" + - "configHash\x121\n" + - "\x15last_drift_check_unix\x18\x05 \x01(\x03R\x12lastDriftCheckUnix\x12<\n" + - "\x1asensitive_outputs_redacted\x18\x06 \x03(\tR\x18sensitiveOutputsRedacted\"\xfa\x01\n" + - "\x17AdminListResourcesInput\x12\x1f\n" + - "\vtype_filter\x18\x01 \x01(\tR\n" + - "typeFilter\x12'\n" + - "\x0fprovider_filter\x18\x02 \x01(\tR\x0eproviderFilter\x12,\n" + - "\x12app_context_filter\x18\x03 \x01(\tR\x10appContextFilter\x12\x19\n" + - "\benv_name\x18\x04 \x01(\tR\aenvName\x12?\n" + - "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x06\x10dJ\x05\be\x10\xc8\x01\"\x82\x01\n" + - "\x18AdminListResourcesOutput\x12C\n" + - "\tresources\x18\x01 \x03(\v2%.workflow.iac.v1.AdminResourceSummaryR\tresources\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\x94\x01\n" + - "\x15AdminGetResourceInput\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + - "\benv_name\x18\x02 \x01(\tR\aenvName\x12?\n" + - "\bevidence\x18\x03 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x04\x10dJ\x05\be\x10\xc8\x01\"}\n" + - "\x16AdminGetResourceOutput\x12@\n" + - "\bresource\x18\x01 \x01(\v2$.workflow.iac.v1.AdminResourceDetailR\bresource\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\x98\x03\n" + - "\x0eAdminFieldSpec\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + - "\x04kind\x18\x03 \x01(\tR\x04kind\x12\x1a\n" + - "\brequired\x18\x04 \x01(\bR\brequired\x12\x1f\n" + - "\venum_values\x18\x05 \x03(\tR\n" + - "enumValues\x12\x1f\n" + - "\venum_source\x18\x06 \x01(\tR\n" + - "enumSource\x12 \n" + - "\vdescription\x18\a \x01(\tR\vdescription\x12#\n" + - "\rdefault_value\x18\b \x01(\tR\fdefaultValue\x12\x1c\n" + - "\tsensitive\x18\t \x01(\bR\tsensitive\x12!\n" + - "\felement_kind\x18\n" + - " \x01(\tR\velementKind\x12\x1b\n" + - "\tmin_count\x18\v \x01(\x05R\bminCount\x12\x1b\n" + - "\tmax_count\x18\f \x01(\x05R\bmaxCount\x12(\n" + - "\x10depends_on_field\x18\r \x01(\tR\x0edependsOnField\"\xe9\x01\n" + - "\x19AdminResourceTypeMetadata\x12\x12\n" + - "\x04type\x18\x01 \x01(\tR\x04type\x12,\n" + - "\x12config_message_fqn\x18\x02 \x01(\tR\x10configMessageFqn\x127\n" + - "\x06fields\x18\x03 \x03(\v2\x1f.workflow.iac.v1.AdminFieldSpecR\x06fields\x12/\n" + - "\x13supported_providers\x18\x04 \x03(\tR\x12supportedProviders\x12 \n" + - "\vdescription\x18\x05 \x01(\tR\vdescription\"\x94\x01\n" + - "\x1bAdminListResourceTypesInput\x12'\n" + - "\x0fprovider_filter\x18\x01 \x01(\tR\x0eproviderFilter\x12?\n" + - "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\x83\x01\n" + - "\x1cAdminListResourceTypesOutput\x12@\n" + - "\x05types\x18\x01 \x03(\v2*.workflow.iac.v1.AdminResourceTypeMetadataR\x05types\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\xaa\x02\n" + - "\x14AdminProviderSummary\x12\x1f\n" + - "\vmodule_name\x18\x01 \x01(\tR\n" + - "moduleName\x12#\n" + - "\rprovider_type\x18\x02 \x01(\tR\fproviderType\x12\"\n" + - "\fcapabilities\x18\x03 \x03(\tR\fcapabilities\x12+\n" + - "\x11supported_regions\x18\x04 \x03(\tR\x10supportedRegions\x12'\n" + - "\x0fsupported_types\x18\x05 \x03(\tR\x0esupportedTypes\x12+\n" + - "\x11supported_engines\x18\x06 \x03(\tR\x10supportedEngines\x12%\n" + - "\x0eregions_source\x18\a \x01(\tR\rregionsSource\"\x82\x01\n" + - "\x17AdminListProvidersInput\x12\x19\n" + - "\benv_name\x18\x01 \x01(\tR\aenvName\x12?\n" + - "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\x82\x01\n" + - "\x18AdminListProvidersOutput\x12C\n" + - "\tproviders\x18\x01 \x03(\v2%.workflow.iac.v1.AdminProviderSummaryR\tproviders\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\xfa\x02\n" + - "\x18AdminGenerateConfigInput\x12#\n" + - "\rresource_type\x18\x01 \x01(\tR\fresourceType\x12#\n" + - "\rresource_name\x18\x02 \x01(\tR\fresourceName\x12'\n" + - "\x0fprovider_module\x18\x03 \x01(\tR\x0eproviderModule\x12]\n" + - "\ffield_values\x18\x04 \x03(\v2:.workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntryR\vfieldValues\x12?\n" + - "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\x1a>\n" + - "\x10FieldValuesEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x06\x10dJ\x05\be\x10\xc8\x01\"\x8e\x01\n" + - "\x19AdminGenerateConfigOutput\x12!\n" + - "\fyaml_snippet\x18\x01 \x01(\tR\vyamlSnippet\x12+\n" + - "\x11validation_errors\x18\x02 \x03(\tR\x10validationErrors\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\xd6\x01\n" + - "\x0fAdminAuditEntry\x12%\n" + - "\x0eschema_version\x18\x01 \x01(\x05R\rschemaVersion\x12\x17\n" + - "\ats_unix\x18\x02 \x01(\x03R\x06tsUnix\x12\x18\n" + - "\asubject\x18\x03 \x01(\tR\asubject\x12\x16\n" + - "\x06action\x18\x04 \x01(\tR\x06action\x12\x18\n" + - "\atargets\x18\x05 \x03(\tR\atargets\x12\x16\n" + - "\x06result\x18\x06 \x01(\tR\x06result\x12\x1f\n" + - "\vapp_context\x18\a \x01(\tR\n" + - "appContext\"\xa8\x01\n" + - "\x0eAdminPlanInput\x12\x1f\n" + - "\vapp_context\x18\x01 \x01(\tR\n" + - "appContext\x12'\n" + - "\x0fresource_filter\x18\x02 \x01(\tR\x0eresourceFilter\x12?\n" + - "\bevidence\x18\x03 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x04\x10dJ\x05\be\x10\xc8\x01\"\x9f\x01\n" + - "\x0fAdminPlanAction\x12\x1f\n" + - "\vaction_type\x18\x01 \x01(\tR\n" + - "actionType\x12#\n" + - "\rresource_name\x18\x02 \x01(\tR\fresourceName\x12\x12\n" + - "\x04type\x18\x03 \x01(\tR\x04type\x12%\n" + - "\x0echange_summary\x18\x04 \x01(\tR\rchangeSummaryJ\x04\b\x05\x10dJ\x05\be\x10\xc8\x01\"\xc9\x01\n" + - "\x0fAdminPlanOutput\x12\x17\n" + - "\aplan_id\x18\x01 \x01(\tR\x06planId\x12!\n" + - "\fdesired_hash\x18\x02 \x01(\tR\vdesiredHash\x12:\n" + - "\aactions\x18\x03 \x03(\v2 .workflow.iac.v1.AdminPlanActionR\aactions\x12\x1b\n" + - "\tplan_json\x18\x04 \x01(\fR\bplanJson\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x05\x10dJ\x05\be\x10\xc8\x01\"\xe1\x01\n" + - "\x0fAdminApplyInput\x12\x17\n" + - "\aplan_id\x18\x01 \x01(\tR\x06planId\x12!\n" + - "\fdesired_hash\x18\x02 \x01(\tR\vdesiredHash\x12#\n" + - "\rallow_replace\x18\x03 \x03(\tR\fallowReplace\x12\x1f\n" + - "\vapp_context\x18\x04 \x01(\tR\n" + - "appContext\x12?\n" + - "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x06\x10dJ\x05\be\x10\xc8\x01\"\xb1\x01\n" + - "\x10AdminApplyOutput\x12?\n" + - "\aapplied\x18\x01 \x03(\v2%.workflow.iac.v1.AdminResourceSummaryR\aapplied\x129\n" + - "\x06errors\x18\x02 \x03(\v2!.workflow.iac.v1.AdminActionErrorR\x06errors\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"i\n" + - "\x10AdminActionError\x12\x1a\n" + - "\bresource\x18\x01 \x01(\tR\bresource\x12\x16\n" + - "\x06action\x18\x02 \x01(\tR\x06action\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05errorJ\x04\b\x04\x10dJ\x05\be\x10\xc8\x01\"G\n" + - "\x10AdminResourceRef\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04typeJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\xbb\x01\n" + - "\x11AdminDestroyInput\x125\n" + - "\x04refs\x18\x01 \x03(\v2!.workflow.iac.v1.AdminResourceRefR\x04refs\x12!\n" + - "\fconfirm_hash\x18\x02 \x01(\tR\vconfirmHash\x12?\n" + - "\bevidence\x18\x03 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x04\x10dJ\x05\be\x10\xc8\x01\"\x90\x01\n" + - "\x12AdminDestroyOutput\x12\x1c\n" + - "\tdestroyed\x18\x01 \x03(\tR\tdestroyed\x129\n" + - "\x06errors\x18\x02 \x03(\v2!.workflow.iac.v1.AdminActionErrorR\x06errors\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\x96\x01\n" + - "\x0fAdminDriftInput\x125\n" + - "\x04refs\x18\x01 \x03(\v2!.workflow.iac.v1.AdminResourceRefR\x04refs\x12?\n" + - "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\xa0\x01\n" + - "\x10AdminDriftResult\x12#\n" + - "\rresource_name\x18\x01 \x01(\tR\fresourceName\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\x12\x18\n" + - "\adrifted\x18\x03 \x01(\bR\adrifted\x12\x14\n" + - "\x05class\x18\x04 \x01(\tR\x05class\x12\x16\n" + - "\x06fields\x18\x05 \x03(\tR\x06fieldsJ\x04\b\x06\x10dJ\x05\be\x10\xc8\x01\"n\n" + - "\x10AdminDriftOutput\x127\n" + - "\x05drift\x18\x01 \x03(\v2!.workflow.iac.v1.AdminDriftResultR\x05drift\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x012\xfa\x06\n" + - "\x11InfraAdminService\x12d\n" + - "\rListResources\x12(.workflow.iac.v1.AdminListResourcesInput\x1a).workflow.iac.v1.AdminListResourcesOutput\x12^\n" + - "\vGetResource\x12&.workflow.iac.v1.AdminGetResourceInput\x1a'.workflow.iac.v1.AdminGetResourceOutput\x12p\n" + - "\x11ListResourceTypes\x12,.workflow.iac.v1.AdminListResourceTypesInput\x1a-.workflow.iac.v1.AdminListResourceTypesOutput\x12d\n" + - "\rListProviders\x12(.workflow.iac.v1.AdminListProvidersInput\x1a).workflow.iac.v1.AdminListProvidersOutput\x12g\n" + - "\x0eGenerateConfig\x12).workflow.iac.v1.AdminGenerateConfigInput\x1a*.workflow.iac.v1.AdminGenerateConfigOutput\x12Q\n" + - "\fPlanResource\x12\x1f.workflow.iac.v1.AdminPlanInput\x1a .workflow.iac.v1.AdminPlanOutput\x12T\n" + - "\rApplyResource\x12 .workflow.iac.v1.AdminApplyInput\x1a!.workflow.iac.v1.AdminApplyOutput\x12Z\n" + - "\x0fDestroyResource\x12\".workflow.iac.v1.AdminDestroyInput\x1a#.workflow.iac.v1.AdminDestroyOutput\x12Y\n" + - "\x12DriftCheckResource\x12 .workflow.iac.v1.AdminDriftInput\x1a!.workflow.iac.v1.AdminDriftOutputB9Z7github.com/GoCodeAlone/workflow/iac/admin/proto;adminpbb\x06proto3" - -var ( - file_iac_admin_proto_infra_admin_proto_rawDescOnce sync.Once - file_iac_admin_proto_infra_admin_proto_rawDescData []byte -) - -func file_iac_admin_proto_infra_admin_proto_rawDescGZIP() []byte { - file_iac_admin_proto_infra_admin_proto_rawDescOnce.Do(func() { - file_iac_admin_proto_infra_admin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_iac_admin_proto_infra_admin_proto_rawDesc), len(file_iac_admin_proto_infra_admin_proto_rawDesc))) - }) - return file_iac_admin_proto_infra_admin_proto_rawDescData -} - -var file_iac_admin_proto_infra_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 30) -var file_iac_admin_proto_infra_admin_proto_goTypes = []any{ - (*AdminAuthzEvidence)(nil), // 0: workflow.iac.v1.AdminAuthzEvidence - (*AdminResourceSummary)(nil), // 1: workflow.iac.v1.AdminResourceSummary - (*AdminResourceDetail)(nil), // 2: workflow.iac.v1.AdminResourceDetail - (*AdminListResourcesInput)(nil), // 3: workflow.iac.v1.AdminListResourcesInput - (*AdminListResourcesOutput)(nil), // 4: workflow.iac.v1.AdminListResourcesOutput - (*AdminGetResourceInput)(nil), // 5: workflow.iac.v1.AdminGetResourceInput - (*AdminGetResourceOutput)(nil), // 6: workflow.iac.v1.AdminGetResourceOutput - (*AdminFieldSpec)(nil), // 7: workflow.iac.v1.AdminFieldSpec - (*AdminResourceTypeMetadata)(nil), // 8: workflow.iac.v1.AdminResourceTypeMetadata - (*AdminListResourceTypesInput)(nil), // 9: workflow.iac.v1.AdminListResourceTypesInput - (*AdminListResourceTypesOutput)(nil), // 10: workflow.iac.v1.AdminListResourceTypesOutput - (*AdminProviderSummary)(nil), // 11: workflow.iac.v1.AdminProviderSummary - (*AdminListProvidersInput)(nil), // 12: workflow.iac.v1.AdminListProvidersInput - (*AdminListProvidersOutput)(nil), // 13: workflow.iac.v1.AdminListProvidersOutput - (*AdminGenerateConfigInput)(nil), // 14: workflow.iac.v1.AdminGenerateConfigInput - (*AdminGenerateConfigOutput)(nil), // 15: workflow.iac.v1.AdminGenerateConfigOutput - (*AdminAuditEntry)(nil), // 16: workflow.iac.v1.AdminAuditEntry - (*AdminPlanInput)(nil), // 17: workflow.iac.v1.AdminPlanInput - (*AdminPlanAction)(nil), // 18: workflow.iac.v1.AdminPlanAction - (*AdminPlanOutput)(nil), // 19: workflow.iac.v1.AdminPlanOutput - (*AdminApplyInput)(nil), // 20: workflow.iac.v1.AdminApplyInput - (*AdminApplyOutput)(nil), // 21: workflow.iac.v1.AdminApplyOutput - (*AdminActionError)(nil), // 22: workflow.iac.v1.AdminActionError - (*AdminResourceRef)(nil), // 23: workflow.iac.v1.AdminResourceRef - (*AdminDestroyInput)(nil), // 24: workflow.iac.v1.AdminDestroyInput - (*AdminDestroyOutput)(nil), // 25: workflow.iac.v1.AdminDestroyOutput - (*AdminDriftInput)(nil), // 26: workflow.iac.v1.AdminDriftInput - (*AdminDriftResult)(nil), // 27: workflow.iac.v1.AdminDriftResult - (*AdminDriftOutput)(nil), // 28: workflow.iac.v1.AdminDriftOutput - nil, // 29: workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntry -} -var file_iac_admin_proto_infra_admin_proto_depIdxs = []int32{ - 1, // 0: workflow.iac.v1.AdminResourceDetail.summary:type_name -> workflow.iac.v1.AdminResourceSummary - 0, // 1: workflow.iac.v1.AdminListResourcesInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 1, // 2: workflow.iac.v1.AdminListResourcesOutput.resources:type_name -> workflow.iac.v1.AdminResourceSummary - 0, // 3: workflow.iac.v1.AdminGetResourceInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 2, // 4: workflow.iac.v1.AdminGetResourceOutput.resource:type_name -> workflow.iac.v1.AdminResourceDetail - 7, // 5: workflow.iac.v1.AdminResourceTypeMetadata.fields:type_name -> workflow.iac.v1.AdminFieldSpec - 0, // 6: workflow.iac.v1.AdminListResourceTypesInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 8, // 7: workflow.iac.v1.AdminListResourceTypesOutput.types:type_name -> workflow.iac.v1.AdminResourceTypeMetadata - 0, // 8: workflow.iac.v1.AdminListProvidersInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 11, // 9: workflow.iac.v1.AdminListProvidersOutput.providers:type_name -> workflow.iac.v1.AdminProviderSummary - 29, // 10: workflow.iac.v1.AdminGenerateConfigInput.field_values:type_name -> workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntry - 0, // 11: workflow.iac.v1.AdminGenerateConfigInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 0, // 12: workflow.iac.v1.AdminPlanInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 18, // 13: workflow.iac.v1.AdminPlanOutput.actions:type_name -> workflow.iac.v1.AdminPlanAction - 0, // 14: workflow.iac.v1.AdminApplyInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 1, // 15: workflow.iac.v1.AdminApplyOutput.applied:type_name -> workflow.iac.v1.AdminResourceSummary - 22, // 16: workflow.iac.v1.AdminApplyOutput.errors:type_name -> workflow.iac.v1.AdminActionError - 23, // 17: workflow.iac.v1.AdminDestroyInput.refs:type_name -> workflow.iac.v1.AdminResourceRef - 0, // 18: workflow.iac.v1.AdminDestroyInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 22, // 19: workflow.iac.v1.AdminDestroyOutput.errors:type_name -> workflow.iac.v1.AdminActionError - 23, // 20: workflow.iac.v1.AdminDriftInput.refs:type_name -> workflow.iac.v1.AdminResourceRef - 0, // 21: workflow.iac.v1.AdminDriftInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence - 27, // 22: workflow.iac.v1.AdminDriftOutput.drift:type_name -> workflow.iac.v1.AdminDriftResult - 3, // 23: workflow.iac.v1.InfraAdminService.ListResources:input_type -> workflow.iac.v1.AdminListResourcesInput - 5, // 24: workflow.iac.v1.InfraAdminService.GetResource:input_type -> workflow.iac.v1.AdminGetResourceInput - 9, // 25: workflow.iac.v1.InfraAdminService.ListResourceTypes:input_type -> workflow.iac.v1.AdminListResourceTypesInput - 12, // 26: workflow.iac.v1.InfraAdminService.ListProviders:input_type -> workflow.iac.v1.AdminListProvidersInput - 14, // 27: workflow.iac.v1.InfraAdminService.GenerateConfig:input_type -> workflow.iac.v1.AdminGenerateConfigInput - 17, // 28: workflow.iac.v1.InfraAdminService.PlanResource:input_type -> workflow.iac.v1.AdminPlanInput - 20, // 29: workflow.iac.v1.InfraAdminService.ApplyResource:input_type -> workflow.iac.v1.AdminApplyInput - 24, // 30: workflow.iac.v1.InfraAdminService.DestroyResource:input_type -> workflow.iac.v1.AdminDestroyInput - 26, // 31: workflow.iac.v1.InfraAdminService.DriftCheckResource:input_type -> workflow.iac.v1.AdminDriftInput - 4, // 32: workflow.iac.v1.InfraAdminService.ListResources:output_type -> workflow.iac.v1.AdminListResourcesOutput - 6, // 33: workflow.iac.v1.InfraAdminService.GetResource:output_type -> workflow.iac.v1.AdminGetResourceOutput - 10, // 34: workflow.iac.v1.InfraAdminService.ListResourceTypes:output_type -> workflow.iac.v1.AdminListResourceTypesOutput - 13, // 35: workflow.iac.v1.InfraAdminService.ListProviders:output_type -> workflow.iac.v1.AdminListProvidersOutput - 15, // 36: workflow.iac.v1.InfraAdminService.GenerateConfig:output_type -> workflow.iac.v1.AdminGenerateConfigOutput - 19, // 37: workflow.iac.v1.InfraAdminService.PlanResource:output_type -> workflow.iac.v1.AdminPlanOutput - 21, // 38: workflow.iac.v1.InfraAdminService.ApplyResource:output_type -> workflow.iac.v1.AdminApplyOutput - 25, // 39: workflow.iac.v1.InfraAdminService.DestroyResource:output_type -> workflow.iac.v1.AdminDestroyOutput - 28, // 40: workflow.iac.v1.InfraAdminService.DriftCheckResource:output_type -> workflow.iac.v1.AdminDriftOutput - 32, // [32:41] is the sub-list for method output_type - 23, // [23:32] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name -} - -func init() { file_iac_admin_proto_infra_admin_proto_init() } -func file_iac_admin_proto_infra_admin_proto_init() { - if File_iac_admin_proto_infra_admin_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_iac_admin_proto_infra_admin_proto_rawDesc), len(file_iac_admin_proto_infra_admin_proto_rawDesc)), - NumEnums: 0, - NumMessages: 30, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_iac_admin_proto_infra_admin_proto_goTypes, - DependencyIndexes: file_iac_admin_proto_infra_admin_proto_depIdxs, - MessageInfos: file_iac_admin_proto_infra_admin_proto_msgTypes, - }.Build() - File_iac_admin_proto_infra_admin_proto = out.File - file_iac_admin_proto_infra_admin_proto_goTypes = nil - file_iac_admin_proto_infra_admin_proto_depIdxs = nil -} diff --git a/iac/admin/proto/infra_admin.proto b/iac/admin/proto/infra_admin.proto deleted file mode 100644 index 1dcde9af..00000000 --- a/iac/admin/proto/infra_admin.proto +++ /dev/null @@ -1,429 +0,0 @@ -// infra_admin.proto — typed contracts for the host-side infra.admin -// workflow module's HTTP surface. Sole serialization shape is -// protojson over HTTP. v1 is read-only — mutations remain in -// `wfctl infra apply/destroy/drift`. -// -// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Strict Proto Contracts -// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 4) -// -// Hard invariants: -// - Every typed input carries AdminAuthzEvidence. Read endpoints -// require evidence.authz_checked && evidence.authz_allowed. -// Default-deny semantics. -// - Free-form per-resource AppliedConfig / Outputs payloads cross -// the wire as bytes _json, JSON-encoded by the handler. -// The handler owns the serialization shape; the proto carries -// opaque bytes (same pattern as plugin/external/proto/iac.proto). -// - error field uses tag 100 (uniform discriminator across outputs -// so generic decoders can sniff for a non-empty error before -// consuming the typed payload). -// - The HTTP audit-tail endpoint (GET /api/infra-admin/audit) is -// NOT exposed as a gRPC RPC — it streams ndjson of -// AdminAuditEntry directly per design doc §Access logging. The -// 5 declared RPCs cover every typed read endpoint. - -syntax = "proto3"; - -package workflow.iac.v1; - -option go_package = "github.com/GoCodeAlone/workflow/iac/admin/proto;adminpb"; - -// AdminAuthzEvidence carries the host-side authz outcome to handler -// library functions. The handler MUST refuse to serve when -// authz_checked is false (caller bypassed admin auth middleware) or -// authz_allowed is false (caller was denied). Subject + granted -// permissions are recorded in the audit log alongside each request. -message AdminAuthzEvidence { - bool authz_checked = 1; - bool authz_allowed = 2; - string subject = 3; - repeated string granted_permissions = 4; -} - -// AdminResourceSummary is the table-row shape for the resources list -// page. provider_module is the host YAML `name:` of the iac.provider -// module that owns this resource; provider_type is the underlying -// cloud provider string (aws/digitalocean/etc) read from that -// module's config.provider field at module Init time. -message AdminResourceSummary { - string name = 1; - string type = 2; // "infra.vpc" - string provider_module = 3; // host module name (the YAML `name:`) - string provider_type = 4; // from the iac.provider module's config `provider:` field - string provider_id = 5; - string status = 6; - int64 updated_at_unix = 7; - repeated string dependencies = 8; - string app_context = 9; -} - -// AdminResourceDetail extends the summary with the full per-resource -// state payload. applied_config_json + outputs_json are JSON-encoded -// by the handler; sensitive_outputs_redacted lists output keys whose -// values were stripped from outputs_json before serialization (the -// raw key names are safe to surface; the values are not). -message AdminResourceDetail { - AdminResourceSummary summary = 1; - bytes applied_config_json = 2; - bytes outputs_json = 3; - string config_hash = 4; - int64 last_drift_check_unix = 5; - repeated string sensitive_outputs_redacted = 6; -} - -// AdminListResourcesInput is the request shape for the ListResources -// RPC. Filters narrow the returned set; evidence carries the host-side -// authz outcome (default-deny semantics). -message AdminListResourcesInput { - string type_filter = 1; - string provider_filter = 2; - string app_context_filter = 3; - string env_name = 4; - AdminAuthzEvidence evidence = 5; - // Tag 6-99 reserved for future filter additions; tag 100 is the - // uniform error discriminator on Output messages (Input messages - // don't carry error but reserve the tag for symmetry). - reserved 6 to 99, 101 to 199; -} - -// AdminListResourcesOutput is the response shape for the ListResources -// RPC. error is set when the handler refused (e.g. authz denied); when -// non-empty, consumers MUST ignore the typed payload. -message AdminListResourcesOutput { - repeated AdminResourceSummary resources = 1; - string error = 100; - // Tag 2-99 reserved for future Output-payload fields; tag 101-199 - // reserved for future Output-side error/diagnostic metadata. - reserved 2 to 99, 101 to 199; -} - -// AdminGetResourceInput is the request shape for the GetResource RPC. -// name is the resource's host-side identity (the YAML `name:`); the -// returned detail includes the full applied config + outputs. -message AdminGetResourceInput { - string name = 1; - string env_name = 2; - AdminAuthzEvidence evidence = 3; - reserved 4 to 99, 101 to 199; -} - -// AdminGetResourceOutput is the response shape for the GetResource RPC. -// Carries AdminResourceDetail when found; error when the resource is -// missing or authz denied. -message AdminGetResourceOutput { - AdminResourceDetail resource = 1; - string error = 100; - reserved 2 to 99, 101 to 199; -} - -// AdminFieldSpec describes one field of a typed infra.* Config so the -// new-resource form-builder can render the right input control. -// - kind: one of {enum, enum_dynamic, string, number, bool, -// array_string, array_object, object} — see catalog/fields.go. -// - enum_source (only for kind == enum_dynamic): "providers" | -// "regions" | "sizes" | "engines" — the form fetches dynamic -// options at render time. -// - depends_on_field: when set, this field's options are filtered -// by the value the user picked for depends_on_field (e.g. region -// options depend on provider). -// - element_kind: only meaningful for array_* kinds. -// - sensitive: when true, the form renders a masked input AND the -// value is excluded from any rendered preview. -message AdminFieldSpec { - string name = 1; - string label = 2; - string kind = 3; - bool required = 4; - repeated string enum_values = 5; - string enum_source = 6; - string description = 7; - string default_value = 8; - bool sensitive = 9; - string element_kind = 10; - int32 min_count = 11; - int32 max_count = 12; - string depends_on_field = 13; -} - -// AdminResourceTypeMetadata is the form-builder's view of one -// infra.* Config message. config_message_fqn is the fully-qualified -// proto message name in workflow-plugin-infra/internal/contracts/ -// infra.proto (e.g. "workflow.plugins.infra.v1.VPCConfig" — note -// the plural "plugins" matching the vendored proto's package -// declaration; earlier doc-example used "plugin" singular which -// didn't match the wire, see spec-reviewer T6 F2 on commit 1ea231fdd) -// so callers can correlate against the vendored proto descriptor. -message AdminResourceTypeMetadata { - string type = 1; - string config_message_fqn = 2; - repeated AdminFieldSpec fields = 3; - repeated string supported_providers = 4; - string description = 5; -} - -// AdminListResourceTypesInput is the request shape for the -// ListResourceTypes RPC. provider_filter narrows the returned types to -// those a given provider supports. -message AdminListResourceTypesInput { - string provider_filter = 1; - AdminAuthzEvidence evidence = 2; - reserved 3 to 99, 101 to 199; -} - -// AdminListResourceTypesOutput is the response shape for the -// ListResourceTypes RPC. types is the form-builder's view of every -// registered infra.* Config (one entry per FieldSpecCatalog type). -message AdminListResourceTypesOutput { - repeated AdminResourceTypeMetadata types = 1; - string error = 100; - reserved 2 to 99, 101 to 199; -} - -// AdminProviderSummary is the ListProviders response row. supported_regions -// comes from IaCProviderRegionLister when the provider advertises it and the -// call succeeds; otherwise it falls back to the host-side catalog -// (regions.go). supported_types / supported_engines remain host catalogued. -// regions_source is "provider-lister" or "local-catalog". -message AdminProviderSummary { - string module_name = 1; - string provider_type = 2; - repeated string capabilities = 3; - repeated string supported_regions = 4; - repeated string supported_types = 5; - repeated string supported_engines = 6; - string regions_source = 7; // "provider-lister" or "local-catalog" -} - -// AdminListProvidersInput is the request shape for the ListProviders -// RPC. env_name selects the per-environment overlay. -message AdminListProvidersInput { - string env_name = 1; - AdminAuthzEvidence evidence = 2; - reserved 3 to 99, 101 to 199; -} - -// AdminListProvidersOutput is the response shape for the ListProviders -// RPC. One AdminProviderSummary per iac.provider module declared in the -// host's WorkflowConfig. -message AdminListProvidersOutput { - repeated AdminProviderSummary providers = 1; - string error = 100; - reserved 2 to 99, 101 to 199; -} - -// AdminGenerateConfigInput carries the form-builder submission. -// field_values is keyed by AdminFieldSpec.name; values are always -// string-encoded (the catalog's kind metadata is the authority for -// coercion to proto types). resource_name is the host YAML `name:` -// the user picked; provider_module references one of the -// AdminProviderSummary.module_name values returned by ListProviders. -message AdminGenerateConfigInput { - string resource_type = 1; - string resource_name = 2; - string provider_module = 3; - // field_values carries the form-builder submission keyed by - // AdminFieldSpec.name. Single-valued fields (string/enum/bool/ - // number) are encoded as their literal string. Array-shaped - // fields (kind ∈ {array_string, array_object, array_number, - // array_enum_dynamic}) are JSON-encoded — e.g. - // field_values["ingress"] = "[\"rule a\", \"rule b, c\"]" - // — so values containing commas, quotes, or other delimiters - // survive the wire losslessly. The server decodes via - // json.Unmarshal in iac/admin/handler/generate_config.go. - // - // Cross-task contract locked 2026-05-27 between T6 (server) + - // T10-T12 (form-builder JS). Defensive fallback in the handler - // wraps a non-JSON literal into a one-element array so a - // malformed UI submission doesn't crash the server. - map field_values = 4; - AdminAuthzEvidence evidence = 5; - reserved 6 to 99, 101 to 199; -} - -// AdminGenerateConfigOutput is the response shape for the -// GenerateConfig RPC. yaml_snippet is the typed-coerced YAML the form -// produced; validation_errors carries any per-field validation -// failures the catalog reported (form remains submittable on -// validation_errors — `error` is reserved for handler-level failures -// like authz denial). -message AdminGenerateConfigOutput { - string yaml_snippet = 1; - repeated string validation_errors = 2; - string error = 100; - reserved 3 to 99, 101 to 199; -} - -// AdminAuditEntry is the line shape for the audit log (one entry per -// non-noop admin action) AND the streaming response shape of the -// HTTP audit-tail endpoint (GET /api/infra-admin/audit). schema_version -// starts at 1; bumps are additive (new fields) until a breaking -// change forces a major. -message AdminAuditEntry { - int32 schema_version = 1; - int64 ts_unix = 2; - string subject = 3; - string action = 4; - repeated string targets = 5; - string result = 6; - string app_context = 7; -} - -// InfraAdminService is the typed read surface (v1) plus the v1.1 -// mutation surface. v1 has 5 read RPCs; v1.1 adds 4 mutation RPCs -// (Plan/Apply/Destroy/DriftCheck). The HTTP audit-tail endpoint -// (GET /api/infra-admin/audit) streams AdminAuditEntry ndjson outside -// this gRPC service per design doc §Access logging. -// -// v1.1 mutation security chain (per ADR-0007 + ADR-0008): -// auth(401) → secHdrs → requireBearer(401/CSRF) → -// server-side authz.Enforce(subject, perm, "allow")(403) → -// desired_hash/confirm_hash TOCTOU → ValidateAllowReplaceProtected → -// per-provider TryLock(409) -// Client-supplied AdminAuthzEvidence is audit-only; authz is -// server-authoritative. -service InfraAdminService { - rpc ListResources(AdminListResourcesInput) returns (AdminListResourcesOutput); - rpc GetResource(AdminGetResourceInput) returns (AdminGetResourceOutput); - rpc ListResourceTypes(AdminListResourceTypesInput) returns (AdminListResourceTypesOutput); - rpc ListProviders(AdminListProvidersInput) returns (AdminListProvidersOutput); - rpc GenerateConfig(AdminGenerateConfigInput) returns (AdminGenerateConfigOutput); - // v1.1 mutation RPCs — require write-tier auth (bearer + RBAC). - rpc PlanResource(AdminPlanInput) returns (AdminPlanOutput); - rpc ApplyResource(AdminApplyInput) returns (AdminApplyOutput); - rpc DestroyResource(AdminDestroyInput) returns (AdminDestroyOutput); - rpc DriftCheckResource(AdminDriftInput) returns (AdminDriftOutput); -} - -// --- v1.1 mutation messages --- - -// AdminPlanInput is the request shape for the PlanResource RPC. -// Plans only the in-process config (no client-proposed desired state — -// see NEW-M-3). evidence is audit-only; server-side authz.Enforce -// gates the handler (ADR-0007). -message AdminPlanInput { - string app_context = 1; - string resource_filter = 2; - AdminAuthzEvidence evidence = 3; - reserved 4 to 99, 101 to 199; -} - -// AdminPlanAction is one proposed change in the plan (create/update/ -// replace/delete). change_summary is a human-readable diff summary. -message AdminPlanAction { - string action_type = 1; - string resource_name = 2; - string type = 3; - string change_summary = 4; - reserved 5 to 99, 101 to 199; -} - -// AdminPlanOutput is the response shape for the PlanResource RPC. -// plan_id is opaque; desired_hash is the SHA-256 of the resolved -// desired-state (same input as wfctlhelpers.DesiredStateHash) — the -// client MUST echo it in AdminApplyInput for TOCTOU protection. -// plan_json carries the full plan payload as opaque bytes. -// error tag-100: non-empty → consumer ignores typed payload. -message AdminPlanOutput { - string plan_id = 1; - string desired_hash = 2; - repeated AdminPlanAction actions = 3; - bytes plan_json = 4; - string error = 100; - reserved 5 to 99, 101 to 199; -} - -// AdminApplyInput is the request shape for the ApplyResource RPC. -// plan_id + desired_hash MUST match the values returned by a prior -// PlanResource call (TOCTOU protection per ADR-0008). allow_replace -// is the explicit opt-in list for replace actions; the handler calls -// ValidateAllowReplaceProtected against it. evidence is audit-only. -message AdminApplyInput { - string plan_id = 1; - string desired_hash = 2; - repeated string allow_replace = 3; - string app_context = 4; - AdminAuthzEvidence evidence = 5; - reserved 6 to 99, 101 to 199; -} - -// AdminApplyOutput is the response shape for the ApplyResource RPC. -// applied carries summaries of successfully applied resources; errors -// carries per-resource failures (provider errors redacted of creds). -// error tag-100: top-level failure (authz / stale-hash / etc). -message AdminApplyOutput { - repeated AdminResourceSummary applied = 1; - repeated AdminActionError errors = 2; - string error = 100; - reserved 3 to 99, 101 to 199; -} - -// AdminActionError carries a per-resource error from an apply or -// destroy operation. Provider error messages have credentials -// redacted before serialization. -message AdminActionError { - string resource = 1; - string action = 2; - string error = 3; - reserved 4 to 99, 101 to 199; -} - -// AdminResourceRef identifies a resource by name + type for destroy -// and drift-check operations. -message AdminResourceRef { - string name = 1; - string type = 2; - reserved 3 to 99, 101 to 199; -} - -// AdminDestroyInput is the request shape for the DestroyResource RPC. -// refs lists the resources to destroy. confirm_hash is the -// desired-state hash the client must echo for TOCTOU protection -// (same semantics as AdminApplyInput.desired_hash). evidence is -// audit-only. -message AdminDestroyInput { - repeated AdminResourceRef refs = 1; - string confirm_hash = 2; - AdminAuthzEvidence evidence = 3; - reserved 4 to 99, 101 to 199; -} - -// AdminDestroyOutput is the response shape for the DestroyResource -// RPC. destroyed lists the names of successfully destroyed resources; -// errors carries per-resource failures. error tag-100: top-level. -message AdminDestroyOutput { - repeated string destroyed = 1; - repeated AdminActionError errors = 2; - string error = 100; - reserved 3 to 99, 101 to 199; -} - -// AdminDriftInput is the request shape for the DriftCheckResource RPC. -// refs narrows the check to the listed resources; an empty refs list -// checks all resources known to the provider. evidence is audit-only. -message AdminDriftInput { - repeated AdminResourceRef refs = 1; - AdminAuthzEvidence evidence = 2; - reserved 3 to 99, 101 to 199; -} - -// AdminDriftResult is one resource's drift check outcome. drifted is -// true when the live cloud state diverges from desired; class is the -// drift category (e.g. "config", "presence"); fields lists the -// specific diverged field names. -message AdminDriftResult { - string resource_name = 1; - string type = 2; - bool drifted = 3; - string class = 4; - repeated string fields = 5; - reserved 6 to 99, 101 to 199; -} - -// AdminDriftOutput is the response shape for the DriftCheckResource -// RPC. drift carries one entry per checked resource. error tag-100: -// top-level failure (authz / provider unavailable / etc). -message AdminDriftOutput { - repeated AdminDriftResult drift = 1; - string error = 100; - reserved 2 to 99, 101 to 199; -} diff --git a/iac/admin/proto/proto_roundtrip_test.go b/iac/admin/proto/proto_roundtrip_test.go deleted file mode 100644 index 00fe0255..00000000 --- a/iac/admin/proto/proto_roundtrip_test.go +++ /dev/null @@ -1,566 +0,0 @@ -package adminpb_test - -import ( - "testing" - - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "google.golang.org/protobuf/encoding/protojson" -) - -// TestAdminListResourcesInput_Roundtrip is the plan §Task 4 Step 3 -// smoke test: protojson.Marshal + Unmarshal round-trips the typed -// input without losing scalar fields or the nested authz evidence. -// Wire format is protojson per design §Strict Proto Contracts. -func TestAdminListResourcesInput_Roundtrip(t *testing.T) { - in := &adminpb.AdminListResourcesInput{ - TypeFilter: "infra.vpc", - ProviderFilter: "do-provider", - EnvName: "staging", - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: "user:alice", - GrantedPermissions: []string{"infra:read"}, - }, - } - bytes, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminListResourcesInput - if err := protojson.Unmarshal(bytes, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if out.TypeFilter != "infra.vpc" { - t.Errorf("type_filter lost: got %q", out.TypeFilter) - } - if out.ProviderFilter != "do-provider" { - t.Errorf("provider_filter lost: got %q", out.ProviderFilter) - } - if out.EnvName != "staging" { - t.Errorf("env_name lost: got %q", out.EnvName) - } - if out.Evidence == nil { - t.Fatal("evidence dropped from round-trip") - } - if !out.Evidence.AuthzChecked || !out.Evidence.AuthzAllowed { - t.Errorf("evidence booleans lost: checked=%v allowed=%v", out.Evidence.AuthzChecked, out.Evidence.AuthzAllowed) - } - if out.Evidence.Subject != "user:alice" { - t.Errorf("subject lost: got %q", out.Evidence.Subject) - } - if len(out.Evidence.GrantedPermissions) != 1 || out.Evidence.GrantedPermissions[0] != "infra:read" { - t.Errorf("granted_permissions lost: got %v", out.Evidence.GrantedPermissions) - } -} - -// TestAdminResourceDetail_Roundtrip exercises the bytes-shaped -// applied_config_json + outputs_json fields. The handler library -// JSON-encodes the free-form per-resource payloads into these bytes; -// the test pins that protojson preserves the byte sequence without -// re-encoding as base64-then-misinterpreting on Unmarshal. -func TestAdminResourceDetail_Roundtrip(t *testing.T) { - applied := []byte(`{"region":"nyc3","name":"site-vpc"}`) - outputs := []byte(`{"id":"vpc-abc123"}`) - in := &adminpb.AdminResourceDetail{ - Summary: &adminpb.AdminResourceSummary{ - Name: "site-vpc", - Type: "infra.vpc", - ProviderModule: "do-provider", - ProviderType: "digitalocean", - ProviderId: "vpc-abc123", - Status: "active", - }, - AppliedConfigJson: applied, - OutputsJson: outputs, - ConfigHash: "sha256:deadbeef", - LastDriftCheckUnix: 1716800000, - SensitiveOutputsRedacted: []string{"private_key"}, - } - bytes, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminResourceDetail - if err := protojson.Unmarshal(bytes, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if string(out.AppliedConfigJson) != string(applied) { - t.Errorf("applied_config_json mangled: got %q want %q", out.AppliedConfigJson, applied) - } - if string(out.OutputsJson) != string(outputs) { - t.Errorf("outputs_json mangled: got %q want %q", out.OutputsJson, outputs) - } - if out.Summary == nil || out.Summary.Name != "site-vpc" { - t.Errorf("summary lost: %+v", out.Summary) - } - if len(out.SensitiveOutputsRedacted) != 1 || out.SensitiveOutputsRedacted[0] != "private_key" { - t.Errorf("sensitive_outputs_redacted lost: %v", out.SensitiveOutputsRedacted) - } - if out.LastDriftCheckUnix != 1716800000 { - t.Errorf("last_drift_check_unix lost: got %d", out.LastDriftCheckUnix) - } -} - -// TestAdminGenerateConfigInput_FieldValuesMap pins protojson's -// map handling. The form-builder submission is -// keyed by AdminFieldSpec.name; lost keys or value-type coercion -// would silently break catalog-driven config generation. -func TestAdminGenerateConfigInput_FieldValuesMap(t *testing.T) { - in := &adminpb.AdminGenerateConfigInput{ - ResourceType: "infra.vpc", - ResourceName: "site-vpc", - ProviderModule: "do-provider", - FieldValues: map[string]string{ - "region": "nyc3", - "name": "site-vpc", - "ip_range": "10.10.0.0/16", - }, - Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, - } - bytes, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminGenerateConfigInput - if err := protojson.Unmarshal(bytes, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if len(out.FieldValues) != 3 { - t.Errorf("field_values size lost: got %d, want 3", len(out.FieldValues)) - } - for k, want := range map[string]string{"region": "nyc3", "name": "site-vpc", "ip_range": "10.10.0.0/16"} { - if got := out.FieldValues[k]; got != want { - t.Errorf("field_values[%q] = %q, want %q", k, got, want) - } - } -} - -// TestAdminListResourcesOutput_ErrorField pins the discriminator -// tag-100 convention: outputs carry a `error` field at tag 100 so -// generic decoders can sniff for a non-empty error before consuming -// the typed payload. -func TestAdminListResourcesOutput_ErrorField(t *testing.T) { - in := &adminpb.AdminListResourcesOutput{Error: "authz denied"} - bytes, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminListResourcesOutput - if err := protojson.Unmarshal(bytes, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if out.Error != "authz denied" { - t.Errorf("error lost: got %q", out.Error) - } - if len(out.Resources) != 0 { - t.Errorf("resources should be empty on error response: got %v", out.Resources) - } -} - -// --- T5 mutation message round-trips --- - -// TestAdminPlanInput_Roundtrip pins the AdminPlanInput wire shape: -// app_context + resource_filter survive protojson; evidence nested -// with authz_checked/authz_allowed/subject/granted_permissions. -func TestAdminPlanInput_Roundtrip(t *testing.T) { - in := &adminpb.AdminPlanInput{ - AppContext: "myapp", - ResourceFilter: "infra.vpc", - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: "user:bob", - GrantedPermissions: []string{"infra:read"}, - }, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminPlanInput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if out.AppContext != "myapp" { - t.Errorf("app_context lost: got %q", out.AppContext) - } - if out.ResourceFilter != "infra.vpc" { - t.Errorf("resource_filter lost: got %q", out.ResourceFilter) - } - if out.Evidence == nil || out.Evidence.Subject != "user:bob" { - t.Errorf("evidence/subject lost: %+v", out.Evidence) - } -} - -// TestAdminPlanOutput_Roundtrip checks plan_id, desired_hash, actions, -// plan_json and that error is reachable at tag 100. -func TestAdminPlanOutput_Roundtrip(t *testing.T) { - planJSON := []byte(`{"actions":[{"type":"create"}]}`) - in := &adminpb.AdminPlanOutput{ - PlanId: "plan-abc", - DesiredHash: "abc123", - Actions: []*adminpb.AdminPlanAction{ - {ActionType: "create", ResourceName: "site-vpc", Type: "infra.vpc", ChangeSummary: "+vpc"}, - }, - PlanJson: planJSON, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminPlanOutput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if out.PlanId != "plan-abc" { - t.Errorf("plan_id lost: got %q", out.PlanId) - } - if out.DesiredHash != "abc123" { - t.Errorf("desired_hash lost: got %q", out.DesiredHash) - } - if len(out.Actions) != 1 || out.Actions[0].ActionType != "create" { - t.Errorf("actions lost: %+v", out.Actions) - } - if out.Actions[0].ChangeSummary != "+vpc" { - t.Errorf("change_summary lost: got %q", out.Actions[0].ChangeSummary) - } - if string(out.PlanJson) != string(planJSON) { - t.Errorf("plan_json mangled: got %q want %q", out.PlanJson, planJSON) - } - - // tag-100 error discriminator — fix F1: capture marshal err; fix F3: typed fields empty. - errOut := &adminpb.AdminPlanOutput{Error: "authz denied"} - eb, err := protojson.Marshal(errOut) - if err != nil { - t.Fatalf("error-case Marshal: %v", err) - } - var errRt adminpb.AdminPlanOutput - if err := protojson.Unmarshal(eb, &errRt); err != nil { - t.Fatalf("error roundtrip Unmarshal: %v", err) - } - if errRt.Error != "authz denied" { - t.Errorf("AdminPlanOutput error tag-100 lost: got %q", errRt.Error) - } - // F3: error response must have no typed payload fields set. - if errRt.PlanId != "" { - t.Errorf("expect no plan_id on error response; got %q", errRt.PlanId) - } - if errRt.DesiredHash != "" { - t.Errorf("expect no desired_hash on error response; got %q", errRt.DesiredHash) - } - if len(errRt.Actions) != 0 { - t.Errorf("expect no actions on error response; got %v", errRt.Actions) - } -} - -// TestAdminApplyInput_Roundtrip checks plan_id, desired_hash, -// allow_replace, app_context, and evidence survive protojson. -func TestAdminApplyInput_Roundtrip(t *testing.T) { - in := &adminpb.AdminApplyInput{ - PlanId: "plan-abc", - DesiredHash: "abc123", - AllowReplace: []string{"site-vpc"}, - AppContext: "myapp", - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: "user:operator", - }, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminApplyInput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if out.PlanId != "plan-abc" { - t.Errorf("plan_id lost: got %q", out.PlanId) - } - if out.DesiredHash != "abc123" { - t.Errorf("desired_hash lost: got %q", out.DesiredHash) - } - if len(out.AllowReplace) != 1 || out.AllowReplace[0] != "site-vpc" { - t.Errorf("allow_replace lost: got %v", out.AllowReplace) - } - if out.AppContext != "myapp" { - t.Errorf("app_context lost: got %q", out.AppContext) - } - if out.Evidence == nil || out.Evidence.Subject != "user:operator" { - t.Errorf("evidence/subject lost: %+v", out.Evidence) - } -} - -// TestAdminApplyOutput_Roundtrip checks applied summaries, action errors, -// and that error tag-100 survives. -func TestAdminApplyOutput_Roundtrip(t *testing.T) { - in := &adminpb.AdminApplyOutput{ - Applied: []*adminpb.AdminResourceSummary{ - {Name: "site-vpc", Type: "infra.vpc", Status: "active"}, - }, - Errors: []*adminpb.AdminActionError{ - {Resource: "db-main", Action: "create", Error: "timeout"}, - }, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminApplyOutput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if len(out.Applied) != 1 || out.Applied[0].Name != "site-vpc" { - t.Errorf("applied lost: %+v", out.Applied) - } - if len(out.Errors) != 1 || out.Errors[0].Resource != "db-main" { - t.Errorf("errors lost: %+v", out.Errors) - } - if out.Errors[0].Error != "timeout" { - t.Errorf("error field lost: got %q", out.Errors[0].Error) - } - - // tag-100 discriminator — fix F1: capture marshal err; fix F3: typed fields empty. - errOut := &adminpb.AdminApplyOutput{Error: "stale plan"} - eb, err := protojson.Marshal(errOut) - if err != nil { - t.Fatalf("error-case Marshal: %v", err) - } - var errRt adminpb.AdminApplyOutput - if err := protojson.Unmarshal(eb, &errRt); err != nil { - t.Fatalf("error roundtrip: %v", err) - } - if errRt.Error != "stale plan" { - t.Errorf("AdminApplyOutput error tag-100 lost: got %q", errRt.Error) - } - // F3: error response must have no typed payload fields set. - if len(errRt.Applied) != 0 { - t.Errorf("expect no applied on error response; got %v", errRt.Applied) - } - if len(errRt.Errors) != 0 { - t.Errorf("expect no errors on error response; got %v", errRt.Errors) - } -} - -// TestAdminDestroyInput_Roundtrip checks refs (AdminResourceRef), -// confirm_hash, and evidence survive protojson. -func TestAdminDestroyInput_Roundtrip(t *testing.T) { - in := &adminpb.AdminDestroyInput{ - Refs: []*adminpb.AdminResourceRef{ - {Name: "old-vpc", Type: "infra.vpc"}, - }, - ConfirmHash: "hash-xyz", - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: "user:admin", - }, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminDestroyInput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if len(out.Refs) != 1 || out.Refs[0].Name != "old-vpc" { - t.Errorf("refs lost: %+v", out.Refs) - } - if out.Refs[0].Type != "infra.vpc" { - t.Errorf("ref.type lost: got %q", out.Refs[0].Type) - } - if out.ConfirmHash != "hash-xyz" { - t.Errorf("confirm_hash lost: got %q", out.ConfirmHash) - } -} - -// TestAdminDestroyOutput_Roundtrip checks destroyed list, action errors, -// and error tag-100. -func TestAdminDestroyOutput_Roundtrip(t *testing.T) { - in := &adminpb.AdminDestroyOutput{ - Destroyed: []string{"old-vpc"}, - Errors: []*adminpb.AdminActionError{{Resource: "lb-main", Action: "destroy", Error: "in use"}}, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminDestroyOutput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if len(out.Destroyed) != 1 || out.Destroyed[0] != "old-vpc" { - t.Errorf("destroyed lost: %v", out.Destroyed) - } - if len(out.Errors) != 1 || out.Errors[0].Action != "destroy" { - t.Errorf("errors lost: %+v", out.Errors) - } - - // F1: capture marshal err; F3: typed fields empty on error response. - errOut := &adminpb.AdminDestroyOutput{Error: "confirm_hash mismatch"} - eb, err := protojson.Marshal(errOut) - if err != nil { - t.Fatalf("error-case Marshal: %v", err) - } - var errRt adminpb.AdminDestroyOutput - if err := protojson.Unmarshal(eb, &errRt); err != nil { - t.Fatalf("error roundtrip: %v", err) - } - if errRt.Error != "confirm_hash mismatch" { - t.Errorf("AdminDestroyOutput error tag-100 lost: got %q", errRt.Error) - } - // F3: error response must have no typed payload fields set. - if len(errRt.Destroyed) != 0 { - t.Errorf("expect no destroyed on error response; got %v", errRt.Destroyed) - } - if len(errRt.Errors) != 0 { - t.Errorf("expect no errors on error response; got %v", errRt.Errors) - } -} - -// TestAdminDriftInput_Roundtrip checks refs and evidence survive protojson. -func TestAdminDriftInput_Roundtrip(t *testing.T) { - in := &adminpb.AdminDriftInput{ - Refs: []*adminpb.AdminResourceRef{ - {Name: "site-vpc", Type: "infra.vpc"}, - }, - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, - AuthzAllowed: true, - Subject: "user:viewer", - }, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminDriftInput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if len(out.Refs) != 1 || out.Refs[0].Name != "site-vpc" { - t.Errorf("refs lost: %+v", out.Refs) - } - if out.Evidence == nil || out.Evidence.Subject != "user:viewer" { - t.Errorf("evidence/subject lost: %+v", out.Evidence) - } -} - -// TestAdminDriftOutput_Roundtrip checks AdminDriftResult fields and -// error tag-100 survive protojson. -func TestAdminDriftOutput_Roundtrip(t *testing.T) { - in := &adminpb.AdminDriftOutput{ - Drift: []*adminpb.AdminDriftResult{ - { - ResourceName: "site-vpc", - Type: "infra.vpc", - Drifted: true, - Class: "config", - Fields: []string{"region", "ip_range"}, - }, - }, - } - b, err := protojson.Marshal(in) - if err != nil { - t.Fatalf("protojson.Marshal: %v", err) - } - var out adminpb.AdminDriftOutput - if err := protojson.Unmarshal(b, &out); err != nil { - t.Fatalf("protojson.Unmarshal: %v", err) - } - if len(out.Drift) != 1 || out.Drift[0].ResourceName != "site-vpc" { - t.Errorf("drift lost: %+v", out.Drift) - } - if !out.Drift[0].Drifted { - t.Errorf("drifted bool lost") - } - if out.Drift[0].Class != "config" { - t.Errorf("class lost: got %q", out.Drift[0].Class) - } - if len(out.Drift[0].Fields) != 2 || out.Drift[0].Fields[0] != "region" { - t.Errorf("fields lost: %v", out.Drift[0].Fields) - } - - // F1: capture marshal err; F3: typed fields empty on error response. - errOut := &adminpb.AdminDriftOutput{Error: "provider unavailable"} - eb, err := protojson.Marshal(errOut) - if err != nil { - t.Fatalf("error-case Marshal: %v", err) - } - var errRt adminpb.AdminDriftOutput - if err := protojson.Unmarshal(eb, &errRt); err != nil { - t.Fatalf("error roundtrip: %v", err) - } - if errRt.Error != "provider unavailable" { - t.Errorf("AdminDriftOutput error tag-100 lost: got %q", errRt.Error) - } - // F3: error response must have no typed payload fields set. - if len(errRt.Drift) != 0 { - t.Errorf("expect no drift on error response; got %v", errRt.Drift) - } -} - -// TestMutationOutputs_DiscardUnknown verifies forward compatibility: clients -// parse server Output responses and servers parse client Input requests with -// DiscardUnknown:true so future proto additions (new fields) are silently -// ignored rather than rejected (F2: comment corrected to cover both directions). -func TestMutationOutputs_DiscardUnknown(t *testing.T) { - opts := protojson.UnmarshalOptions{DiscardUnknown: true} - - // Output direction: clients parsing server responses (forward compat). - extraPayload := []byte(`{"unknownField":"ignored","plan_id":"p1","desired_hash":"h1"}`) - var planOut adminpb.AdminPlanOutput - if err := opts.Unmarshal(extraPayload, &planOut); err != nil { - t.Errorf("AdminPlanOutput DiscardUnknown: %v", err) - } - if planOut.PlanId != "p1" { - t.Errorf("plan_id not read through DiscardUnknown: got %q", planOut.PlanId) - } - - applyPayload := []byte(`{"unknownField":"ignored","error":"denied"}`) - var applyOut adminpb.AdminApplyOutput - if err := opts.Unmarshal(applyPayload, &applyOut); err != nil { - t.Errorf("AdminApplyOutput DiscardUnknown: %v", err) - } - if applyOut.Error != "denied" { - t.Errorf("error not read through DiscardUnknown: got %q", applyOut.Error) - } - - destroyPayload := []byte(`{"unknownField":"ignored","destroyed":["r1"]}`) - var destroyOut adminpb.AdminDestroyOutput - if err := opts.Unmarshal(destroyPayload, &destroyOut); err != nil { - t.Errorf("AdminDestroyOutput DiscardUnknown: %v", err) - } - if len(destroyOut.Destroyed) != 1 { - t.Errorf("destroyed not read through DiscardUnknown: %v", destroyOut.Destroyed) - } - - driftPayload := []byte(`{"unknownField":"ignored","error":"timeout"}`) - var driftOut adminpb.AdminDriftOutput - if err := opts.Unmarshal(driftPayload, &driftOut); err != nil { - t.Errorf("AdminDriftOutput DiscardUnknown: %v", err) - } - if driftOut.Error != "timeout" { - t.Errorf("error not read through DiscardUnknown: got %q", driftOut.Error) - } - - // Input direction: server parsing client requests (F2: the more security- - // relevant direction — server must not reject valid requests because the - // client sent an extra unknown field from a newer client version). - // module/infra_admin.go uses unmarshalOpts{DiscardUnknown:true} when - // decoding all Input bodies. - applyInputPayload := []byte(`{"futureClientField":"v2","plan_id":"p2","desired_hash":"h2","allow_replace":["r1"]}`) - var applyIn adminpb.AdminApplyInput - if err := opts.Unmarshal(applyInputPayload, &applyIn); err != nil { - t.Errorf("AdminApplyInput DiscardUnknown (server-side): %v", err) - } - if applyIn.PlanId != "p2" { - t.Errorf("plan_id not read through DiscardUnknown (input): got %q", applyIn.PlanId) - } - if len(applyIn.AllowReplace) != 1 || applyIn.AllowReplace[0] != "r1" { - t.Errorf("allow_replace not read through DiscardUnknown (input): got %v", applyIn.AllowReplace) - } -} diff --git a/iac/admin/testdata/infra.proto b/iac/admin/testdata/infra.proto deleted file mode 100644 index f41b67a7..00000000 --- a/iac/admin/testdata/infra.proto +++ /dev/null @@ -1,167 +0,0 @@ -// Vendored from GoCodeAlone/workflow-plugin-infra/internal/contracts/infra.proto -// Source version: v1.0.0 (sourced 2026-05-27) -// Refresh via: make vendor-infra-proto -// Drift detection: catalog_proto_parity_test.go - -syntax = "proto3"; - -package workflow.plugins.infra.v1; - -import "google/protobuf/struct.proto"; - -option go_package = "github.com/GoCodeAlone/workflow-plugin-infra/internal/contracts;contracts"; - -message InfraResourceConfig { - string provider = 1; - string region = 2; - string workspace = 3; - map labels = 4; - google.protobuf.Struct settings = 5; -} - -message ContainerServiceConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string image = 4; - repeated int32 ports = 5; - map env = 6; - int32 replicas = 7; - map labels = 8; - google.protobuf.Struct settings = 9; -} - -message K8SClusterConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string version = 4; - int32 node_count = 5; - string node_size = 6; - map labels = 7; - google.protobuf.Struct settings = 8; -} - -message DatabaseConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string engine = 4; - string version = 5; - string size = 6; - int32 storage_gb = 7; - bool multi_az = 8; - map labels = 9; - google.protobuf.Struct settings = 10; -} - -message CacheConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string engine = 4; - string version = 5; - string size = 6; - int32 nodes = 7; - map labels = 8; - google.protobuf.Struct settings = 9; -} - -message VPCConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string cidr = 4; - repeated string availability_zones = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -message LoadBalancerConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string scheme = 4; - repeated int32 ports = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -message DNSConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string zone = 4; - string record = 5; - string target = 6; - map labels = 7; - google.protobuf.Struct settings = 8; -} - -message RegistryConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string name = 4; - bool public = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -message APIGatewayConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string protocol = 4; - repeated string routes = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -message FirewallConfig { - string provider = 1; - string region = 2; - string workspace = 3; - repeated string ingress = 4; - repeated string egress = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -message IAMRoleConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string name = 4; - repeated string policies = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -message StorageConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string name = 4; - string class = 5; - bool versioning = 6; - map labels = 7; - google.protobuf.Struct settings = 8; -} - -message CertificateConfig { - string provider = 1; - string region = 2; - string workspace = 3; - string domain = 4; - repeated string subject_alt_names = 5; - map labels = 6; - google.protobuf.Struct settings = 7; -} - -// DNSRecordStepConfig / DNSRecordStepInput / DNSRecordStepOutput removed -// in v1.0.0 (Phase 3b). The infra.dns_record step type was deleted because -// peer-dispatch from a step-handler context is architecturally unsupported -// (cycle 3.5 I-NEW-1). Per-record DNS workflows route through -// `wfctl infra apply` (config-declared records) or `wfctl dns-policy *` -// (policy edits) against any provider-plugin that implements infra.dns. diff --git a/iac/admin/ui.go b/iac/admin/ui.go deleted file mode 100644 index 059b4f9a..00000000 --- a/iac/admin/ui.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package admin hosts the host-side infra.admin module's UI assets + -// audit subsystem. The handler library lives in the sibling -// handler/ subpackage; the catalog lives in catalog/; the proto in -// proto/. This package itself exposes only the asset filesystem + -// audit writer surface the host module (workflow/module/infra_admin.go, -// T15) imports. -// -// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md -// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Tasks 13 + 14) -package admin - -import "embed" - -// AssetFS embeds the static UI pages + scripts + styles authored in -// T10-T12 under ui_dist/. The host module (T15) mounts this via -// http.FileServerFS at config.AssetPrefix so the admin dashboard -// iframe can load resources.html / resource.html / new.html. -// -// Per plan §Task 13. The glob covers the three file types the asset -// pages use (.html / .js / .css); future additions (icons, fonts) -// require both extending this glob AND updating -// TestAssetFS_ListsAllAndOnlyExpected so the test catches the change. -// -//go:embed ui_dist/*.html ui_dist/*.js ui_dist/*.css -var AssetFS embed.FS diff --git a/iac/admin/ui_dist/actions.html b/iac/admin/ui_dist/actions.html deleted file mode 100644 index bf510ac5..00000000 --- a/iac/admin/ui_dist/actions.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - Infra Admin — Audit Log - - - -

Infra Admin — Audit Log

-

« Back to resources

- -
- - -
- -
- - - - - - - - - - - -
- - - - - - - - - - - - - -
Time (UTC)SubjectActionTargetsResultApp context
- - - - - - - diff --git a/iac/admin/ui_dist/actions.js b/iac/admin/ui_dist/actions.js deleted file mode 100644 index 12df4c13..00000000 --- a/iac/admin/ui_dist/actions.js +++ /dev/null @@ -1,182 +0,0 @@ -// actions.js — drives /admin/infra-admin/actions.html. -// CSP-compliant: external file only. -// -// Endpoint: -// GET /api/infra-admin/audit?limit=N → ndjson of AdminAuditEntry -// -// Wire format: ndjson — one protojson-encoded AdminAuditEntry per line. -// AdminAuditEntry fields (snake_case): schema_version, ts_unix, subject, -// action, targets[], result, app_context. -// -// Security: Authorization: Bearer on every fetch. -// Filters (client-side after fetch): action, result. -// Auto-refresh: 30-second interval, toggle by checkbox. - -const API = '/api/infra-admin'; -const TOKEN_KEY = 'infra_admin_bearer'; -const REFRESH_INTERVAL_MS = 30_000; - -let autoRefreshTimer = null; -// lastEntries caches the most recent fetch so client-side filter changes -// re-render from memory without a round-trip. -let lastEntries = []; - -// --- helpers --------------------------------------------------------------- - -function esc(s) { - return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ - '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', - }[c])); -} - -function showError(err) { - document.getElementById('error').textContent = err ? String(err) : ''; -} - -function fmtTs(unix) { - if (!unix || unix === '0') return ''; - const n = typeof unix === 'string' ? parseInt(unix, 10) : unix; - if (!Number.isFinite(n) || n === 0) return ''; - return new Date(n * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' Z'); -} - -// bearer returns the token, persisting any freshly-entered value. -function bearer() { - const inp = document.getElementById('bearer-token'); - if (inp.value) { - sessionStorage.setItem(TOKEN_KEY, inp.value); - } else { - const stored = sessionStorage.getItem(TOKEN_KEY); - if (stored) inp.value = stored; - } - return inp.value; -} - -// parseNdjson splits a text body into non-empty lines and JSON-parses each. -// Lines that fail to parse are silently skipped (partial writes in the log). -function parseNdjson(text) { - const entries = []; - for (const line of text.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - entries.push(JSON.parse(trimmed)); - } catch (_) { - // skip malformed lines (partial writes mid-rotation) - } - } - return entries; -} - -// resultClass maps audit result values to CSS classes for styling. -function resultClass(result) { - if (result === 'ok') return 'audit-ok'; - if (result === 'denied') return 'audit-denied'; - if (result === 'error') return 'audit-error'; - return ''; -} - -// --- render ---------------------------------------------------------------- - -function renderEntries(entries) { - const tbody = document.querySelector('#audit-table tbody'); - tbody.innerHTML = ''; - - const filterAction = document.getElementById('filter-action').value; - const filterResult = document.getElementById('filter-result').value; - - // Apply client-side filters (entries already limited server-side by ?limit=). - const filtered = entries.filter(e => { - if (filterAction && e.action !== filterAction) return false; - if (filterResult && e.result !== filterResult) return false; - return true; - }); - - document.getElementById('empty-note').hidden = filtered.length > 0; - - for (const e of filtered) { - const tr = document.createElement('tr'); - const cls = resultClass(e.result); - tr.innerHTML = [ - `${esc(fmtTs(e.ts_unix))}`, - `${esc(e.subject)}`, - `${esc(e.action)}`, - `${esc((e.targets || []).join(', '))}`, - `${esc(e.result)}`, - `${esc(e.app_context)}`, - ].join(''); - tbody.appendChild(tr); - } -} - -// --- fetch ----------------------------------------------------------------- - -// fetchAndCache fetches the audit log, caches results in lastEntries for -// client-side re-filtering, and renders the table. Called by Refresh button, -// limit-change, auto-refresh timer, and initial page load. -async function fetchAndCache() { - const tok = bearer(); - if (!tok) { - showError('bearer token required — paste JWT into the token field above'); - return; - } - - const limit = document.getElementById('filter-limit').value || '50'; - const url = `${API}/audit${limit !== '0' ? `?limit=${encodeURIComponent(limit)}` : ''}`; - - try { - const resp = await fetch(url, { - headers: { 'Authorization': `Bearer ${tok}` }, - }); - if (!resp.ok) { - showError(`audit: HTTP ${resp.status}`); - return; - } - const text = await resp.text(); - showError(''); - lastEntries = parseNdjson(text); - renderEntries(lastEntries); - } catch (err) { - showError(`audit: ${err.message}`); - } -} - -// --- auto-refresh ---------------------------------------------------------- - -function startAutoRefresh() { - stopAutoRefresh(); - // fetchAndCache keeps lastEntries current so filter re-renders stay fresh. - autoRefreshTimer = setInterval(fetchAndCache, REFRESH_INTERVAL_MS); -} - -function stopAutoRefresh() { - if (autoRefreshTimer !== null) { - clearInterval(autoRefreshTimer); - autoRefreshTimer = null; - } -} - -// --- wire events ----------------------------------------------------------- - -document.getElementById('btn-refresh').addEventListener('click', fetchAndCache); - -document.getElementById('auto-refresh').addEventListener('change', function () { - if (this.checked) { - startAutoRefresh(); - } else { - stopAutoRefresh(); - } -}); - -// Filter-select changes re-render from the cache — no round-trip needed. -document.getElementById('filter-action').addEventListener('change', () => renderEntries(lastEntries)); -document.getElementById('filter-result').addEventListener('change', () => renderEntries(lastEntries)); -// Limit change fetches a different server-side slice. -document.getElementById('filter-limit').addEventListener('change', fetchAndCache); - -// Restore stored token on load. -const storedTok = sessionStorage.getItem(TOKEN_KEY); -if (storedTok) document.getElementById('bearer-token').value = storedTok; - -// Initial load. -fetchAndCache(); diff --git a/iac/admin/ui_dist/new.html b/iac/admin/ui_dist/new.html deleted file mode 100644 index fff1d215..00000000 --- a/iac/admin/ui_dist/new.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - Draft New Infra Resource - - - -

Draft New Resource

- -
-
- - - - -
- -
- -
- - -
-
- -
-

Generated YAML

-

-    
- -
-
    -
    - - - - - diff --git a/iac/admin/ui_dist/new.js b/iac/admin/ui_dist/new.js deleted file mode 100644 index 9e5590d9..00000000 --- a/iac/admin/ui_dist/new.js +++ /dev/null @@ -1,450 +0,0 @@ -// new.js — form-builder for /admin/infra-admin/new.html. -// CSP-compliant: external file only. -// -// Endpoints: -// POST /api/infra-admin/types → AdminListResourceTypesOutput -// POST /api/infra-admin/providers → AdminListProvidersOutput -// POST /api/infra-admin/generate-config → AdminGenerateConfigOutput -// -// Wire format: protojson with UseProtoNames=true (snake_case fields). -// -// FieldSpec.kind values handled: -// - "string" → text input (must carry FREEFORM_OK reason on the -// server side; client renders tooltip from .description) -// - "number" → number input with min_count/max_count as min/max -// - "bool" → checkbox -// - "enum" → select populated from .enum_values -// - "enum_dynamic" → select populated from .enum_source resolution: -// "providers" → /providers -// "regions" → ProviderSummary.supported_regions -// (depends_on=provider) -// "engines" → ProviderSummary.supported_engines -// (depends_on=provider) -// "sizes" → fixed [xs, s, m, l, xl] -// - "array_string" → repeatable text inputs (add/remove) -// - "array_number" → repeatable number inputs -// - "array_enum" → repeatable enum selects - -const API = '/api/infra-admin'; -const SIZE_OPTIONS = ['xs', 's', 'm', 'l', 'xl']; - -// In-memory caches populated at load time. -const STATE = { - types: [], // AdminResourceTypeMetadata[] - providers: [], // AdminProviderSummary[] - selectedType: null, // AdminResourceTypeMetadata -}; - -function esc(s) { - return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ - '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', - }[c])); -} - -function showError(err) { - document.getElementById('error').textContent = err ? String(err) : ''; -} - -async function postJSON(path, body) { - const resp = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); - const data = await resp.json(); - if (data && data.error) throw new Error(data.error); - return data; -} - -function providerByModule(name) { - return STATE.providers.find(p => p.module_name === name); -} - -// --- field rendering ------------------------------------------------------- - -function makeLabel(spec) { - const lbl = document.createElement('label'); - lbl.className = 'field-label'; - lbl.setAttribute('for', `field-${spec.name}`); - lbl.textContent = spec.label || spec.name; - if (spec.required) lbl.textContent += ' *'; - return lbl; -} - -function makeHelp(spec) { - if (!spec.description) return null; - const help = document.createElement('div'); - help.className = 'field-help'; - help.textContent = spec.description; - return help; -} - -function makeStringInput(spec) { - const inp = document.createElement('input'); - inp.type = 'text'; - inp.id = `field-${spec.name}`; - inp.name = spec.name; - if (spec.required) inp.required = true; - if (spec.default_value) inp.value = spec.default_value; - if (spec.sensitive) inp.type = 'password'; - if (spec.description) inp.title = spec.description; - return inp; -} - -function makeNumberInput(spec) { - const inp = document.createElement('input'); - inp.type = 'number'; - inp.id = `field-${spec.name}`; - inp.name = spec.name; - if (spec.required) inp.required = true; - if (spec.min_count != null && spec.min_count !== 0) inp.min = spec.min_count; - if (spec.max_count != null && spec.max_count !== 0) inp.max = spec.max_count; - if (spec.default_value) inp.value = spec.default_value; - return inp; -} - -function makeBoolInput(spec) { - const inp = document.createElement('input'); - inp.type = 'checkbox'; - inp.id = `field-${spec.name}`; - inp.name = spec.name; - if (spec.default_value === 'true') inp.checked = true; - return inp; -} - -function makeSelect(spec, options) { - const sel = document.createElement('select'); - sel.id = `field-${spec.name}`; - sel.name = spec.name; - if (spec.required) sel.required = true; - const blank = document.createElement('option'); - blank.value = ''; - blank.textContent = spec.required ? '— select —' : '(any)'; - sel.appendChild(blank); - for (const v of options) { - const opt = document.createElement('option'); - opt.value = String(v); - opt.textContent = String(v); - if (spec.default_value === String(v)) opt.selected = true; - sel.appendChild(opt); - } - return sel; -} - -function resolveDynamicEnum(spec, formState) { - switch (spec.enum_source) { - case 'providers': - return STATE.providers.map(p => p.module_name); - case 'sizes': - return SIZE_OPTIONS.slice(); - case 'regions': { - const providerMod = formState[spec.depends_on_field || 'provider']; - const p = providerByModule(providerMod); - return p ? (p.supported_regions || []).slice() : []; - } - case 'engines': { - const providerMod = formState[spec.depends_on_field || 'provider']; - const p = providerByModule(providerMod); - return p ? (p.supported_engines || []).slice() : []; - } - case 'resource_types': - return STATE.types.map(t => t.type); - default: - return []; - } -} - -function makeArrayField(spec, elementKind) { - const wrap = document.createElement('div'); - wrap.className = 'array-field'; - wrap.dataset.name = spec.name; - wrap.dataset.kind = elementKind; - - const rows = document.createElement('div'); - rows.className = 'array-rows'; - wrap.appendChild(rows); - - const addBtn = document.createElement('button'); - addBtn.type = 'button'; - addBtn.textContent = `+ Add ${spec.label || spec.name}`; - addBtn.addEventListener('click', () => addArrayRow(spec, rows, elementKind)); - wrap.appendChild(addBtn); - - // Seed with min_count rows (or 1 if min_count > 0 / required). - const seed = Math.max(spec.min_count || 0, spec.required ? 1 : 0); - for (let i = 0; i < seed; i++) addArrayRow(spec, rows, elementKind); - return wrap; -} - -function addArrayRow(spec, rows, elementKind) { - const row = document.createElement('div'); - row.className = 'array-row'; - let input; - switch (elementKind) { - case 'number': - input = document.createElement('input'); - input.type = 'number'; - if (spec.min_count != null && spec.min_count !== 0) input.min = spec.min_count; - if (spec.max_count != null && spec.max_count !== 0) input.max = spec.max_count; - break; - case 'enum': - input = makeSelect( - { name: `${spec.name}_item`, required: false, default_value: '' }, - spec.enum_values || [], - ); - break; - case 'enum_dynamic': - input = makeSelect( - { name: `${spec.name}_item`, required: false, default_value: '' }, - resolveDynamicEnum(spec, snapshotFormState()), - ); - // Code-review I-1: tag the row's select with the same - // data-enum-dynamic / data-depends-on attributes that - // refreshDependentDynamics() looks for, so changing the parent - // field (e.g. provider) rebuilds the array rows' options too. - // Without these, array_enum_dynamic dropdowns go stale. - if (spec.enum_source) input.dataset.enumDynamic = spec.enum_source; - if (spec.depends_on_field) input.dataset.dependsOn = spec.depends_on_field; - break; - default: - input = document.createElement('input'); - input.type = 'text'; - } - input.dataset.arrayItem = '1'; - input.name = `${spec.name}[]`; - const rm = document.createElement('button'); - rm.type = 'button'; - rm.textContent = '–'; - rm.addEventListener('click', () => row.remove()); - row.appendChild(input); - row.appendChild(rm); - rows.appendChild(row); -} - -function renderField(spec) { - const row = document.createElement('div'); - row.className = 'field-row'; - row.appendChild(makeLabel(spec)); - - const right = document.createElement('div'); - let widget; - switch (spec.kind) { - case 'string': - widget = makeStringInput(spec); - break; - case 'number': - widget = makeNumberInput(spec); - break; - case 'bool': - widget = makeBoolInput(spec); - break; - case 'enum': - widget = makeSelect(spec, spec.enum_values || []); - break; - case 'enum_dynamic': - widget = makeSelect(spec, resolveDynamicEnum(spec, snapshotFormState())); - widget.dataset.enumDynamic = spec.enum_source || ''; - if (spec.depends_on_field) widget.dataset.dependsOn = spec.depends_on_field; - break; - case 'array_string': - widget = makeArrayField(spec, 'string'); - break; - case 'array_number': - widget = makeArrayField(spec, 'number'); - break; - case 'array_enum': - widget = makeArrayField(spec, 'enum'); - break; - case 'array_enum_dynamic': - widget = makeArrayField(spec, 'enum_dynamic'); - break; - default: - // Unknown kind — degrade to text input with a warning tooltip. - widget = makeStringInput(spec); - widget.title = `unknown kind ${spec.kind}; rendered as text`; - } - right.appendChild(widget); - const help = makeHelp(spec); - if (help) right.appendChild(help); - row.appendChild(right); - return row; -} - -// --- form lifecycle -------------------------------------------------------- - -function snapshotFormState() { - const out = {}; - const fields = document.getElementById('fields'); - if (!fields) return out; - for (const el of fields.querySelectorAll('input, select')) { - if (el.dataset.arrayItem === '1') continue; - if (el.type === 'checkbox') { - out[el.name] = el.checked ? 'true' : 'false'; - } else if (el.name) { - out[el.name] = el.value; - } - } - return out; -} - -function refreshDependentDynamics() { - // Recompute enum_dynamic selects whose depends_on field changed. - const state = snapshotFormState(); - document.querySelectorAll('select[data-enum-dynamic]').forEach(sel => { - const src = sel.dataset.enumDynamic; - const deps = sel.dataset.dependsOn; - if (!deps) return; // independent dynamic; populated at render - const spec = { - name: sel.name, - enum_source: src, - depends_on_field: deps, - required: sel.required, - }; - const options = resolveDynamicEnum(spec, state); - const prev = sel.value; - // Repopulate while preserving placeholder. - while (sel.options.length > 1) sel.remove(1); - for (const v of options) { - const opt = document.createElement('option'); - opt.value = String(v); - opt.textContent = String(v); - sel.appendChild(opt); - } - if (options.includes(prev)) sel.value = prev; - }); -} - -function renderType(typeMeta) { - STATE.selectedType = typeMeta; - const wrap = document.getElementById('fields'); - wrap.innerHTML = ''; - if (!typeMeta) return; - for (const f of (typeMeta.fields || [])) { - wrap.appendChild(renderField(f)); - } - // Wire up dependency refresh on every input change. - wrap.addEventListener('change', refreshDependentDynamics); -} - -function readSubmittedFieldValues() { - // Collect array values into JS arrays first, scalars into strings, - // then JSON.stringify the arrays into their map slots - // before returning. Code-review I-2: per spec-reviewer + code-reviewer - // contract lock, array field_values are JSON-encoded so the server - // (T6 GenerateConfig) can `json.Unmarshal([]byte(s), &arr)` and - // recover the original slice — robust against array elements that - // contain commas (firewall rule DSLs, etc.). Scalars stay as plain - // strings to keep simple paths cheap. - const scalars = {}; - const arrays = {}; - const fields = document.getElementById('fields'); - for (const el of fields.querySelectorAll('input, select')) { - if (!el.name) continue; - if (el.type === 'checkbox') { - scalars[el.name] = el.checked ? 'true' : 'false'; - continue; - } - if (el.name.endsWith('[]')) { - const key = el.name.slice(0, -2); - const val = el.value; - if (val === '' || val == null) continue; - if (!arrays[key]) arrays[key] = []; - arrays[key].push(val); - continue; - } - if (el.value !== '' && el.value != null) scalars[el.name] = el.value; - } - const out = { ...scalars }; - for (const key of Object.keys(arrays)) { - out[key] = JSON.stringify(arrays[key]); - } - return out; -} - -async function loadCatalog() { - showError(''); - try { - const [typesResp, provResp] = await Promise.all([ - postJSON(`${API}/types`, { - evidence: { authz_checked: true, authz_allowed: true }, - }), - postJSON(`${API}/providers`, { - evidence: { authz_checked: true, authz_allowed: true }, - }), - ]); - STATE.types = typesResp.types || []; - STATE.providers = provResp.providers || []; - - const sel = document.getElementById('type'); - while (sel.options.length > 1) sel.remove(1); - for (const t of STATE.types) { - const opt = document.createElement('option'); - opt.value = t.type; - opt.textContent = t.description ? `${t.type} — ${t.description}` : t.type; - sel.appendChild(opt); - } - } catch (err) { - showError(`load catalog: ${err.message}`); - } -} - -async function onSubmit(ev) { - ev.preventDefault(); - showError(''); - const errBox = document.getElementById('validation-errors'); - errBox.innerHTML = ''; - const out = document.getElementById('yaml-output'); - out.textContent = ''; - document.getElementById('copy').disabled = true; - - const typeName = document.getElementById('type').value; - const resourceName = document.getElementById('name').value.trim(); - if (!typeName || !resourceName) { - showError('type and name are required'); - return; - } - const fieldValues = readSubmittedFieldValues(); - // provider_module is taken from the `provider` field if present; - // catalog convention assigns enum_source=providers to a field named - // `provider`, whose value is the module name. - const providerModule = fieldValues.provider || ''; - - try { - const resp = await postJSON(`${API}/generate-config`, { - resource_type: typeName, - resource_name: resourceName, - provider_module: providerModule, - field_values: fieldValues, - evidence: { authz_checked: true, authz_allowed: true }, - }); - if (resp.validation_errors && resp.validation_errors.length > 0) { - for (const e of resp.validation_errors) { - const li = document.createElement('li'); - li.textContent = e; - errBox.appendChild(li); - } - } - out.textContent = resp.yaml_snippet || ''; - document.getElementById('copy').disabled = !resp.yaml_snippet; - } catch (err) { - showError(`generate-config: ${err.message}`); - } -} - -function onCopy() { - const out = document.getElementById('yaml-output'); - if (!out.textContent) return; - navigator.clipboard.writeText(out.textContent).catch(err => { - showError(`copy: ${err.message}`); - }); -} - -document.getElementById('type').addEventListener('change', ev => { - const tm = STATE.types.find(t => t.type === ev.target.value); - renderType(tm || null); -}); -document.getElementById('new-resource-form').addEventListener('submit', onSubmit); -document.getElementById('copy').addEventListener('click', onCopy); - -loadCatalog(); diff --git a/iac/admin/ui_dist/resource.html b/iac/admin/ui_dist/resource.html deleted file mode 100644 index 4a478cef..00000000 --- a/iac/admin/ui_dist/resource.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - Infra Resource Detail - - - -

    Resource Detail

    -

    « Back to resources

    - -
    -

    Summary

    -
    -
    - -
    -

    Applied Config

    -
    
    -  
    - -
    -

    Outputs

    -
    
    -    

    -
    - - -
    -

    Mutations

    - -
    - - - - Required for plan / apply / destroy / drift. Stored in sessionStorage for this tab only. - -
    - -
    - - -
    - - - - - -
    - - -
    - - - - - -
    -
    - - - - - diff --git a/iac/admin/ui_dist/resource.js b/iac/admin/ui_dist/resource.js deleted file mode 100644 index c41bc886..00000000 --- a/iac/admin/ui_dist/resource.js +++ /dev/null @@ -1,409 +0,0 @@ -// resource.js — drives /admin/infra-admin/resource.html?name=. -// CSP-compliant: external file only. -// -// Endpoints (read): -// POST /api/infra-admin/resources/{name} → AdminGetResourceOutput -// -// Endpoints (v1.1 mutation — bearer required): -// POST /api/infra-admin/plan → AdminPlanOutput -// POST /api/infra-admin/apply → AdminApplyOutput -// POST /api/infra-admin/destroy → AdminDestroyOutput -// POST /api/infra-admin/drift → AdminDriftOutput -// -// Wire format: protojson with UseProtoNames=true (snake_case fields). -// applied_config_json / outputs_json arrive as base64-encoded `bytes` -// per protojson convention. Decoded to a JSON object for display. -// -// Mutation security: -// All mutation fetches send Authorization: Bearer . -// allow_replace selections come from checkboxes rendered against -// plan action_type=replace rows (selectable, not free-text). - -const API = '/api/infra-admin'; -const TOKEN_KEY = 'infra_admin_bearer'; - -// In-flight plan state held between Plan and Apply. -const PLAN_STATE = { - planId: '', - desiredHash: '', - actions: [], -}; - -// Current resource state populated at load. -const RESOURCE_STATE = { - name: '', - appContext: '', - type: '', -}; - -// --- helpers --------------------------------------------------------------- - -function esc(s) { - return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ - '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', - }[c])); -} - -function showError(err) { - document.getElementById('error').textContent = err ? String(err) : ''; -} - -function showMutationError(err) { - document.getElementById('mutation-error').textContent = err ? String(err) : ''; - document.getElementById('mutation-ok').textContent = ''; -} - -function showMutationOk(msg) { - document.getElementById('mutation-ok').textContent = msg || ''; - document.getElementById('mutation-error').textContent = ''; -} - -function fmtTs(unix) { - if (!unix || unix === '0') return ''; - const n = typeof unix === 'string' ? parseInt(unix, 10) : unix; - if (!Number.isFinite(n) || n === 0) return ''; - return new Date(n * 1000).toISOString(); -} - -function decodeProtoBytes(b64) { - if (!b64) return null; - try { - const raw = atob(b64); - return JSON.parse(raw); - } catch (_) { - return b64; // fall back to raw if not JSON-shaped bytes - } -} - -// bearer returns the current token, saving any new value from the input. -function bearer() { - const inp = document.getElementById('bearer-token'); - if (inp.value) { - sessionStorage.setItem(TOKEN_KEY, inp.value); - } else { - const stored = sessionStorage.getItem(TOKEN_KEY); - if (stored) inp.value = stored; - } - return inp.value; -} - -async function postJSON(path, body) { - const resp = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); - const data = await resp.json(); - if (data && data.error) throw new Error(data.error); - return data; -} - -// postMutation wraps postJSON and adds the Authorization: Bearer header. -// Throws if no bearer token is configured. -async function postMutation(path, body) { - const tok = bearer(); - if (!tok) throw new Error('bearer token required — paste JWT into the token field above'); - const resp = await fetch(path, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${tok}`, - }, - body: JSON.stringify(body), - }); - if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); - const data = await resp.json(); - if (data && data.error) throw new Error(data.error); - return data; -} - -// --- render helpers -------------------------------------------------------- - -function renderSummary(s) { - const tbody = document.querySelector('#summary-table tbody'); - tbody.innerHTML = ''; - if (!s) return; - const rows = [ - ['Name', s.name], - ['Type', s.type], - ['Provider module', s.provider_module], - ['Provider type', s.provider_type], - ['Provider id', s.provider_id], - ['Status', s.status], - ['App context', s.app_context], - ['Updated', fmtTs(s.updated_at_unix)], - ['Dependencies', (s.dependencies || []).join(', ')], - ]; - for (const [k, v] of rows) { - const tr = document.createElement('tr'); - tr.innerHTML = `${esc(k)}${esc(v)}`; - tbody.appendChild(tr); - } -} - -function renderJSON(elId, obj) { - const el = document.getElementById(elId); - if (obj == null) { - el.textContent = '(empty)'; - return; - } - el.textContent = typeof obj === 'string' - ? obj - : JSON.stringify(obj, null, 2); -} - -function renderRedactionNote(redacted) { - const note = document.getElementById('redacted-note'); - if (!redacted || redacted.length === 0) { - note.textContent = ''; - return; - } - note.textContent = `Redacted output keys: ${redacted.join(', ')}`; - note.classList.add('redacted'); -} - -// renderPlan renders the plan action table and wires the apply confirm -// checkbox → apply button. allow_replace checkboxes are rendered only -// for action_type=replace rows (selectable from the plan, not free-text). -function renderPlan(out) { - PLAN_STATE.planId = out.plan_id || ''; - PLAN_STATE.desiredHash = out.desired_hash || ''; - PLAN_STATE.actions = out.actions || []; - - document.getElementById('plan-meta').textContent = - `plan_id=${PLAN_STATE.planId} desired_hash=${PLAN_STATE.desiredHash}`; - - const tbody = document.querySelector('#plan-actions-table tbody'); - tbody.innerHTML = ''; - - for (const a of PLAN_STATE.actions) { - const isReplace = a.action_type === 'replace'; - const tr = document.createElement('tr'); - tr.innerHTML = [ - `${esc(a.action_type)}`, - `${esc(a.resource_name)}`, - `${esc(a.type)}`, - `${esc(a.change_summary)}`, - `${isReplace - ? `` - : ''}`, - ].join(''); - tbody.appendChild(tr); - } - - document.getElementById('plan-result').hidden = false; - document.getElementById('apply-confirm').checked = false; - document.getElementById('btn-apply').disabled = true; - - document.getElementById('drift-result').hidden = true; - showMutationError(''); - showMutationOk(''); -} - -function renderDrift(drift) { - const tbody = document.querySelector('#drift-table tbody'); - tbody.innerHTML = ''; - - for (const d of (drift || [])) { - const tr = document.createElement('tr'); - tr.innerHTML = [ - `${esc(d.resource_name)}`, - `${esc(d.type)}`, - `${d.drifted ? 'yes' : 'no'}`, - `${esc(d.class)}`, - `${esc((d.fields || []).join(', '))}`, - ].join(''); - tbody.appendChild(tr); - } - - document.getElementById('drift-result').hidden = false; - document.getElementById('plan-result').hidden = true; - showMutationError(''); -} - -// --- load (read) ----------------------------------------------------------- - -async function load() { - const params = new URLSearchParams(window.location.search); - const name = params.get('name'); - if (!name) { - showError('missing ?name= query parameter'); - return; - } - RESOURCE_STATE.name = name; - - try { - // POST /api/infra-admin/resources/{name} — handler reads name from URL - // path; body carries env_name + evidence. Mirror that here. - const body = { - name: name, - evidence: { authz_checked: true, authz_allowed: true }, - }; - const data = await postJSON( - `${API}/resources/${encodeURIComponent(name)}`, - body, - ); - const r = data.resource || {}; - const s = r.summary || {}; - RESOURCE_STATE.appContext = s.app_context || ''; - RESOURCE_STATE.type = s.type || ''; - - renderSummary(s); - renderJSON('applied-config', decodeProtoBytes(r.applied_config_json)); - renderJSON('outputs-json', decodeProtoBytes(r.outputs_json)); - renderRedactionNote(r.sensitive_outputs_redacted || []); - } catch (err) { - showError(`get resource: ${err.message}`); - } - - // Restore stored token into the input field. - const stored = sessionStorage.getItem(TOKEN_KEY); - if (stored) document.getElementById('bearer-token').value = stored; -} - -// --- mutation handlers ----------------------------------------------------- - -async function handlePlan() { - showMutationError(''); - showMutationOk(''); - // F2: disable button during in-flight request to prevent overlapping requests. - const btn = document.getElementById('btn-plan'); - btn.disabled = true; - try { - const data = await postMutation(`${API}/plan`, { - app_context: RESOURCE_STATE.appContext, - resource_filter: RESOURCE_STATE.name, - evidence: { authz_checked: true, authz_allowed: true }, - }); - renderPlan(data); - if ((data.actions || []).length === 0) { - showMutationOk('No changes — resource is up to date.'); - } - } catch (err) { - showMutationError(`plan: ${err.message}`); - } finally { - btn.disabled = false; - } -} - -async function handleApply() { - showMutationError(''); - showMutationOk(''); - if (!PLAN_STATE.planId || !PLAN_STATE.desiredHash) { - showMutationError('run Plan first'); - return; - } - - // Collect allow_replace from checked checkboxes (selectable from plan actions). - const allowReplace = Array.from( - document.querySelectorAll('.allow-replace-cb:checked'), - ).map(cb => cb.value); - - // F2: disable button during in-flight request. - const btn = document.getElementById('btn-apply'); - btn.disabled = true; - try { - const data = await postMutation(`${API}/apply`, { - plan_id: PLAN_STATE.planId, - desired_hash: PLAN_STATE.desiredHash, - allow_replace: allowReplace, - app_context: RESOURCE_STATE.appContext, - evidence: { authz_checked: true, authz_allowed: true }, - }); - const applied = (data.applied || []).map(r => r.name).join(', '); - const errors = (data.errors || []).map(e => `${e.resource}: ${e.error}`).join('; '); - showMutationOk(`Applied: ${applied || '(none)'}`); - if (errors) showMutationError(`Errors: ${errors}`); - document.getElementById('plan-result').hidden = true; - // S-1: clear all plan state (including stale actions) after apply. - PLAN_STATE.planId = ''; - PLAN_STATE.desiredHash = ''; - PLAN_STATE.actions = []; - } catch (err) { - showMutationError(`apply: ${err.message}`); - } finally { - // Restore disabled state based on confirm checkbox (not unconditionally). - btn.disabled = !document.getElementById('apply-confirm').checked; - } -} - -async function handleDestroy() { - showMutationError(''); - showMutationOk(''); - // F1: resource must be fully loaded before we can build a valid ref. - if (!RESOURCE_STATE.type) { - showMutationError('resource not loaded — refresh page'); - return; - } - // I-1: mirror Apply's guard — Destroy carries the same confirm_hash discipline. - // An empty hash defeats TOCTOU protection; require a prior Plan run. - if (!PLAN_STATE.desiredHash) { - showMutationError('run Plan first to obtain a confirm_hash before destroying'); - return; - } - // F2: disable button during in-flight request. - const destroyBtn = document.getElementById('btn-destroy'); - destroyBtn.disabled = true; - try { - const data = await postMutation(`${API}/destroy`, { - refs: [{ name: RESOURCE_STATE.name, type: RESOURCE_STATE.type }], - confirm_hash: PLAN_STATE.desiredHash, - evidence: { authz_checked: true, authz_allowed: true }, - }); - const destroyed = (data.destroyed || []).join(', '); - const errors = (data.errors || []).map(e => `${e.resource}: ${e.error}`).join('; '); - showMutationOk(`Destroyed: ${destroyed || '(none)'}`); - if (errors) showMutationError(`Errors: ${errors}`); - } catch (err) { - showMutationError(`destroy: ${err.message}`); - } finally { - destroyBtn.disabled = !document.getElementById('destroy-confirm').checked; - } -} - -async function handleDrift() { - showMutationError(''); - showMutationOk(''); - // F2: disable button during in-flight request. - const btn = document.getElementById('btn-drift'); - btn.disabled = true; - try { - const data = await postMutation(`${API}/drift`, { - refs: [{ name: RESOURCE_STATE.name, type: RESOURCE_STATE.type }], - evidence: { authz_checked: true, authz_allowed: true }, - }); - renderDrift(data.drift || []); - const anyDrift = (data.drift || []).some(d => d.drifted); - showMutationOk(anyDrift ? 'Drift detected — see table below.' : 'No drift detected.'); - } catch (err) { - showMutationError(`drift: ${err.message}`); - } finally { - btn.disabled = false; - } -} - -// --- wire events ----------------------------------------------------------- - -document.getElementById('btn-plan').addEventListener('click', handlePlan); -document.getElementById('btn-drift').addEventListener('click', handleDrift); - -// Apply confirm checkbox gates the Apply button. -document.getElementById('apply-confirm').addEventListener('change', function () { - document.getElementById('btn-apply').disabled = !this.checked; -}); - -document.getElementById('btn-apply').addEventListener('click', handleApply); - -// Destroy confirm checkbox gates the Destroy button. -document.getElementById('destroy-confirm').addEventListener('change', function () { - document.getElementById('btn-destroy').disabled = !this.checked; -}); - -document.getElementById('btn-destroy').addEventListener('click', handleDestroy); - -// S-2: redundant change listener removed — bearer() already persists the -// token to sessionStorage on every mutation call; no separate change -// listener needed. - -load(); diff --git a/iac/admin/ui_dist/resources.html b/iac/admin/ui_dist/resources.html deleted file mode 100644 index 5e9e508f..00000000 --- a/iac/admin/ui_dist/resources.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - Infra Resources - - - -

    Infra Resources

    -
    - - - - -
    - - - - - -
    NameTypeProviderStatusUpdated
    - - - - diff --git a/iac/admin/ui_dist/resources.js b/iac/admin/ui_dist/resources.js deleted file mode 100644 index ce75a94d..00000000 --- a/iac/admin/ui_dist/resources.js +++ /dev/null @@ -1,110 +0,0 @@ -// resources.js — drives /admin/infra-admin/resources.html. -// CSP-compliant: external file only, no inline scripts/handlers. -// -// Wire format: protojson with UseProtoNames=true on the handler side. -// Field names match workflow/iac/admin/proto/infra_admin.proto snake_case. -// -// Endpoints: -// POST /api/infra-admin/resources → AdminListResourcesOutput -// POST /api/infra-admin/providers → AdminListProvidersOutput (populates filter dropdown) - -const API = '/api/infra-admin'; -const DETAIL_PATH = '/admin/infra-admin/resource.html'; - -function esc(s) { - return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ - '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', - }[c])); -} - -function showError(err) { - document.getElementById('error').textContent = err ? String(err) : ''; -} - -function fmtTs(unix) { - if (!unix || unix === '0') return ''; - const n = typeof unix === 'string' ? parseInt(unix, 10) : unix; - if (!Number.isFinite(n) || n === 0) return ''; - return new Date(n * 1000).toISOString(); -} - -async function postJSON(path, body) { - const resp = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); - const data = await resp.json(); - if (data && data.error) throw new Error(data.error); - return data; -} - -async function loadProviders() { - try { - const data = await postJSON(`${API}/providers`, { - evidence: { authz_checked: true, authz_allowed: true }, - }); - const sel = document.getElementById('filter-provider'); - // Preserve "all providers" first option. - while (sel.options.length > 1) sel.remove(1); - for (const p of (data.providers || [])) { - const opt = document.createElement('option'); - opt.value = p.module_name; - opt.textContent = `${p.module_name} (${p.provider_type || '?'})`; - sel.appendChild(opt); - } - } catch (err) { - showError(`provider list: ${err.message}`); - } -} - -function renderTable(rows) { - const tbody = document.querySelector('#resources tbody'); - tbody.innerHTML = ''; - if (rows.length === 0) { - const tr = document.createElement('tr'); - tr.innerHTML = 'No resources match the current filters.'; - tbody.appendChild(tr); - return; - } - for (const r of rows) { - const tr = document.createElement('tr'); - const href = `${DETAIL_PATH}?name=${encodeURIComponent(r.name || '')}`; - tr.innerHTML = ` - ${esc(r.name)} - ${esc(r.type)} - ${esc(r.provider_module)}${r.provider_type ? ' / ' + esc(r.provider_type) : ''} - ${esc(r.status)} - ${esc(fmtTs(r.updated_at_unix))} - Detail`; - tbody.appendChild(tr); - } -} - -async function fetchResources() { - showError(''); - const body = { - type_filter: document.getElementById('filter-type').value.trim(), - provider_filter: document.getElementById('filter-provider').value, - app_context_filter: document.getElementById('filter-app-context').value.trim(), - evidence: { authz_checked: true, authz_allowed: true }, - }; - try { - const data = await postJSON(`${API}/resources`, body); - renderTable(data.resources || []); - } catch (err) { - showError(`list resources: ${err.message}`); - renderTable([]); - } -} - -document.getElementById('refresh').addEventListener('click', fetchResources); -document.getElementById('filter-type').addEventListener('change', fetchResources); -document.getElementById('filter-provider').addEventListener('change', fetchResources); -document.getElementById('filter-app-context').addEventListener('change', fetchResources); - -(async () => { - await loadProviders(); - await fetchResources(); -})(); diff --git a/iac/admin/ui_dist/styles.css b/iac/admin/ui_dist/styles.css deleted file mode 100644 index 9b667c94..00000000 --- a/iac/admin/ui_dist/styles.css +++ /dev/null @@ -1,28 +0,0 @@ -/* infra-admin: minimal dense operational styling. - Embedded into the host workflow binary via workflow/iac/admin/ui.go. - CSP: default-src 'self'; style-src 'self' — no inline styles, no @import. */ - -* { box-sizing: border-box; } -body { font: 14px/1.45 system-ui, sans-serif; margin: 1rem; color: #1a1a1a; background: #fafafa; } -h1 { font-size: 1.3rem; margin: 0 0 1rem; } -h2 { font-size: 1.1rem; margin: 1.2rem 0 0.5rem; } - -#filters, .form-controls { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; align-items: center; flex-wrap: wrap; } -input, select, button { font: inherit; padding: 0.3rem 0.5rem; border: 1px solid #ccc; border-radius: 3px; background: #fff; } -button { cursor: pointer; background: #f0f0f0; } -button:hover { background: #e6e6e6; } -button[disabled] { cursor: not-allowed; opacity: 0.5; } - -table { border-collapse: collapse; width: 100%; background: #fff; } -th, td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #eee; text-align: left; vertical-align: top; } -th { background: #f4f4f4; font-weight: 600; } -tr:hover td { background: #fafaff; } - -#error { color: #b00020; margin-top: 0.5rem; min-height: 1.2em; } -#yaml-output { background: #f7f7f7; border: 1px solid #ddd; padding: 0.7rem; overflow-x: auto; font: 13px/1.4 ui-monospace, monospace; } -.field-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; margin-bottom: 0.4rem; align-items: center; } -.field-label { font-weight: 500; } -.field-help { color: #666; font-size: 0.85em; } -.array-field { display: flex; flex-direction: column; gap: 0.25rem; } -.array-row { display: flex; gap: 0.3rem; } -.redacted { color: #888; font-style: italic; } diff --git a/iac/admin/ui_test.go b/iac/admin/ui_test.go deleted file mode 100644 index 2a3f73f4..00000000 --- a/iac/admin/ui_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package admin_test - -import ( - "io" - "io/fs" - "strings" - "testing" - - "github.com/GoCodeAlone/workflow/iac/admin" -) - -// TestAssetFS_AllExpectedFilesEmbedded pins the file list the host -// module serves via http.FileServerFS. If the //go:embed directive -// misses a file (typo in glob, file deleted, etc.) the host module's -// GET routes return 404 silently; this test catches the omission at -// build time. -// -// Per plan §Task 13 (T12 additions: actions.html, actions.js). -func TestAssetFS_AllExpectedFilesEmbedded(t *testing.T) { - expected := []string{ - "ui_dist/resources.html", - "ui_dist/resources.js", - "ui_dist/resource.html", - "ui_dist/resource.js", - "ui_dist/new.html", - "ui_dist/new.js", - "ui_dist/styles.css", - // T12: audit-viewer assets. - "ui_dist/actions.html", - "ui_dist/actions.js", - } - for _, path := range expected { - t.Run(path, func(t *testing.T) { - f, err := admin.AssetFS.Open(path) - if err != nil { - t.Fatalf("AssetFS.Open(%q): %v", path, err) - } - defer f.Close() - stat, err := f.Stat() - if err != nil { - t.Fatalf("AssetFS.Stat(%q): %v", path, err) - } - if stat.Size() == 0 { - t.Errorf("%s is empty — embed glob matched but the file is empty", path) - } - }) - } -} - -// TestAssetFS_ListsAllAndOnlyExpected catches accidental inclusion -// of non-asset files (test fixtures, sourcemaps, .DS_Store) AND -// drift in the embed glob coverage. -func TestAssetFS_ListsAllAndOnlyExpected(t *testing.T) { - var got []string - err := fs.WalkDir(admin.AssetFS, "ui_dist", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - got = append(got, path) - return nil - }) - if err != nil { - t.Fatalf("fs.WalkDir: %v", err) - } - // Every embedded file must be .html, .js, or .css — the embed - // directive's glob shape. A future addition (e.g. .png assets) - // requires updating BOTH the //go:embed line + this test. - for _, path := range got { - switch { - case strings.HasSuffix(path, ".html"), - strings.HasSuffix(path, ".js"), - strings.HasSuffix(path, ".css"): - // allowed - default: - t.Errorf("AssetFS contains non-html/js/css file %q — update //go:embed glob OR remove the file", path) - } - } - if len(got) == 0 { - t.Fatal("AssetFS empty — //go:embed glob did not match any files") - } -} - -// --- T13: mutation panel + audit-viewer content assertions ---------------- - -// readAsset is a test helper that reads the full content of an embedded -// asset as a string, failing the test on any error. -func readAsset(t *testing.T, path string) string { - t.Helper() - f, err := admin.AssetFS.Open(path) - if err != nil { - t.Fatalf("AssetFS.Open(%q): %v", path, err) - } - defer f.Close() - data, err := io.ReadAll(f) - if err != nil { - t.Fatalf("io.ReadAll(%q): %v", path, err) - } - return string(data) -} - -// TestResourceHTML_MutationPanelMarkup pins key mutation-panel markup -// elements in resource.html so a future accidental deletion is caught -// at build time rather than at Playwright runtime. -func TestResourceHTML_MutationPanelMarkup(t *testing.T) { - content := readAsset(t, "ui_dist/resource.html") - must := []struct { - id string - what string - }{ - {`id="mutations"`, "mutations section"}, - {`id="bearer-token"`, "bearer token input"}, - {`id="btn-plan"`, "Plan button"}, - {`id="btn-drift"`, "Check Drift button"}, - {`id="plan-result"`, "plan result panel"}, - {`id="plan-actions-table"`, "plan actions table"}, - {`id="apply-confirm"`, "Apply confirm checkbox"}, - {`id="btn-apply"`, "Apply button"}, - {`id="destroy-confirm"`, "Destroy confirm checkbox"}, - {`id="btn-destroy"`, "Destroy button"}, - {`id="drift-result"`, "drift result panel"}, - {`id="mutation-error"`, "mutation error div"}, - } - for _, m := range must { - if !strings.Contains(content, m.id) { - t.Errorf("resource.html missing %s: expected to contain %q", m.what, m.id) - } - } -} - -// TestResourceJS_MutationPanelEndpoints pins that resource.js references -// all four mutation endpoint paths and sends the Authorization header. -func TestResourceJS_MutationPanelEndpoints(t *testing.T) { - content := readAsset(t, "ui_dist/resource.js") - must := []string{ - `${API}/plan`, - `${API}/apply`, - `${API}/destroy`, - `${API}/drift`, - `Authorization`, - `Bearer`, - `PLAN_STATE`, - `allow-replace-cb`, - } - for _, s := range must { - if !strings.Contains(content, s) { - t.Errorf("resource.js missing expected string %q", s) - } - } -} - -// TestActionsHTML_AuditViewerMarkup pins key audit-viewer markup elements -// in actions.html. -func TestActionsHTML_AuditViewerMarkup(t *testing.T) { - content := readAsset(t, "ui_dist/actions.html") - must := []struct { - id string - what string - }{ - {`id="bearer-token"`, "bearer token input"}, - {`id="filter-action"`, "action filter select"}, - {`id="filter-result"`, "result filter select"}, - {`id="filter-limit"`, "limit filter select"}, - {`id="btn-refresh"`, "Refresh button"}, - {`id="auto-refresh"`, "auto-refresh checkbox"}, - {`id="audit-table"`, "audit table"}, - {`id="error"`, "error div"}, - } - for _, m := range must { - if !strings.Contains(content, m.id) { - t.Errorf("actions.html missing %s: expected to contain %q", m.what, m.id) - } - } - // result filter must offer the three canonical result values (no free-text). - for _, v := range []string{`value="ok"`, `value="denied"`, `value="error"`} { - if !strings.Contains(content, v) { - t.Errorf("actions.html result filter missing option %q (selectable only)", v) - } - } -} - -// TestActionsJS_AuditEndpoint pins that actions.js fetches the correct -// audit endpoint with Authorization header and handles ndjson parsing. -// setInterval is pinned to the fetchAndCache call specifically — the -// T12 bug was setInterval(fetchAudit,...) which would pass a bare -// "setInterval" check but leave the cache stale after auto-refresh. -func TestActionsJS_AuditEndpoint(t *testing.T) { - content := readAsset(t, "ui_dist/actions.js") - must := []string{ - `${API}/audit`, - `Authorization`, - `Bearer`, - `parseNdjson`, - `renderEntries`, - `audit-ok`, - `audit-denied`, - `audit-error`, - `setInterval(fetchAndCache,`, // pin the correct callee, not just presence - `sessionStorage`, - } - for _, s := range must { - if !strings.Contains(content, s) { - t.Errorf("actions.js missing expected string %q", s) - } - } -} - -// TestAssetPrefix_FilesAccessibleViaSubFS verifies that the asset files -// are reachable when the embed.FS is Sub'd to "ui_dist" — matching the -// http.FileServer pattern in module/infra_admin.go (fs.Sub strips the -// leading "ui_dist/" so a request for /admin/infra-admin/actions.html -// resolves to actions.html inside the sub-FS). -func TestAssetPrefix_FilesAccessibleViaSubFS(t *testing.T) { - sub, err := fs.Sub(admin.AssetFS, "ui_dist") - if err != nil { - t.Fatalf("fs.Sub: %v", err) - } - for _, name := range []string{ - "actions.html", - "actions.js", - "resource.html", - "resource.js", - } { - f, err := sub.Open(name) - if err != nil { - t.Errorf("sub.Open(%q): %v — asset not reachable via FileServer path", name, err) - continue - } - f.Close() - } -} diff --git a/iac/wfctlhelpers/desired_hash_test.go b/iac/wfctlhelpers/desired_hash_test.go index 831b0be0..35a45709 100644 --- a/iac/wfctlhelpers/desired_hash_test.go +++ b/iac/wfctlhelpers/desired_hash_test.go @@ -3,7 +3,6 @@ package wfctlhelpers_test import ( "testing" - "github.com/GoCodeAlone/workflow/iac/admin/handler" "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -112,58 +111,3 @@ func TestDesiredStateHash_SortOrderIndependent(t *testing.T) { t.Errorf("hash is order-dependent: a=%q b=%q", h1, h2) } } - -// TestDesiredStateHash_MatchesHandlerInlined is the divergence-guard for -// the inlined copy in iac/admin/handler (handler.DesiredHash). Both -// implementations must produce byte-identical digests for the same inputs, -// preventing silent copy-drift after future refactors of either function. -// -// handler.DesiredHash is exported specifically for this cross-package test; -// iac/admin/handler does NOT import iac/wfctlhelpers (no cycle). -func TestDesiredStateHash_MatchesHandlerInlined(t *testing.T) { - cases := []struct { - name string - desired []interfaces.ResourceSpec - current []interfaces.ResourceState - }{ - { - name: "empty", - desired: nil, - current: nil, - }, - { - name: "create-only (no current)", - desired: []interfaces.ResourceSpec{ - {Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, - }, - current: nil, - }, - { - name: "module-ref collapsed", - desired: []interfaces.ResourceSpec{ - {Name: "db1", Type: "infra.database", Config: map[string]any{"vpc_id": "${vpc1.id}"}}, - }, - current: []interfaces.ResourceState{ - {Name: "vpc1", ProviderID: "do-vpc-111"}, - }, - }, - { - // delete branch: resource in current, absent from desired - name: "delete (current-only)", - desired: nil, - current: []interfaces.ResourceState{ - {Name: "old-vpc", Type: "infra.vpc", ProviderID: "do-vpc-999"}, - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - h1 := wfctlhelpers.DesiredStateHash(nil, tc.desired, tc.current, "") - h2 := handler.DesiredHash(nil, tc.desired, tc.current) - if h1 != h2 { - t.Errorf("divergence between wfctlhelpers.DesiredStateHash and handler.DesiredHash:\n wfctlhelpers=%q\n handler= %q", h1, h2) - } - }) - } -} diff --git a/module/infra_admin.go b/module/infra_admin.go deleted file mode 100644 index 3187696e..00000000 --- a/module/infra_admin.go +++ /dev/null @@ -1,1262 +0,0 @@ -package module - -// infra_admin.go (T15) hosts the engine-side `infra.admin` workflow -// module — the integration centerpiece for the host-side IaC admin -// surface. The module wires together every prior task's deliverable: -// -// * Handler library (T5/T6) — pure read-side functions taking -// state / providers / catalogs / proto inputs and returning -// typed proto outputs. -// * State store (T1) — interfaces.IaCStateStore resolved from a -// declared iac.state module via app.GetService. -// * Provider loader (T2/T3) — interfaces.IaCProvider map resolved -// from each declared iac.provider module via app.GetService. -// * providerTypeByModule map (T6 F1) — populated at Init by -// walking the loaded *config.WorkflowConfig via -// app.GetConfigSection("workflow") and reading each -// iac.provider module's config["provider"] string. This is the -// stable identifier handler.ListProviders uses to key the -// region + engine catalogs — provider.Name() returns the -// plugin's display name and would not match the catalogs. -// * FieldSpec + Region + Engine catalogs (T7a/T7b/T8) — three -// in-process tables driving the new-resource form-builder UI. -// * AssetFS (T13) — embedded UI pages served via http.FileServerFS -// at the module's asset_prefix. -// * Audit writer (T14) — protojson-shaped AdminAuditEntry JSONL -// opened at Init when access_log_path is non-empty, closed at -// Stop. FATAL on open failure per design Security Review. -// -// Lifecycle (per design §Module lifecycle): -// * Init resolves state/providers/router/security-headers -// services + the providerTypeByModule map. Catalogs are -// instantiated in-process. -// * Start resolves the workflowEngine service (registered AFTER -// module.Init by engine.configureTriggers), mounts the typed -// API routes + asset routes under the configured prefixes with -// explicit security-headers middleware, then fires the three -// admin-plugin contribution registration pipelines via -// engine.TriggerWorkflow. -// * Stop closes the audit writer (if open). - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "strconv" - "strings" - "sync" - - "github.com/GoCodeAlone/modular" - "github.com/GoCodeAlone/workflow/config" - "github.com/GoCodeAlone/workflow/iac/admin" - "github.com/GoCodeAlone/workflow/iac/admin/audit" - "github.com/GoCodeAlone/workflow/iac/admin/catalog" - "github.com/GoCodeAlone/workflow/iac/admin/handler" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" -) - -// outputError is a type constraint satisfied by all admin output proto -// messages — they all embed an Error string field. Used by -// writeMutationResponse to inspect the output's error without a type switch. -type outputError interface { - proto.Message - GetError() string -} - -// InfraAdminConfig is the YAML-config shape the host expects under -// the `infra.admin` module entry. Field names use snake_case yaml -// tags to match the rest of the workflow config; defaults match the -// design's reference app config. -type InfraAdminConfig struct { - // RoutePrefix is the URL prefix for typed API routes (e.g. - // /api/infra-admin/resources). Default: "/api/infra-admin". - RoutePrefix string `yaml:"route_prefix" json:"route_prefix"` - - // AssetPrefix is the URL prefix for the embedded UI pages - // (e.g. /admin/infra-admin/resources.html). Default: - // "/admin/infra-admin". - AssetPrefix string `yaml:"asset_prefix" json:"asset_prefix"` - - // StateModule names the host's iac.state module to resolve - // via app.GetService for the IaCStateStore handle. - StateModule string `yaml:"state_module" json:"state_module"` - - // HTTPModule names the *StandardHTTPRouter to mount routes on - // (typically "http-router"). Resolved via app.GetService at - // Init and type-asserted at Start before AddRouteWithMiddleware - // calls. - HTTPModule string `yaml:"http_module" json:"http_module"` - - // SecurityHeadersModule names the HTTPMiddleware module to - // attach explicitly on every registered API + asset route. - // Per design §Security Review the SAMEORIGIN + restrictive CSP - // must wrap admin responses even if the host's global - // middleware ordering changes. - SecurityHeadersModule string `yaml:"security_headers_module" json:"security_headers_module"` - - // AuthModule names the HTTPMiddleware module that enforces - // authentication on every infra-admin API + asset route. Per - // design §Security Review: "All /api/infra-admin/* and - // /admin/infra-admin/* sit behind the host's auth route - // filter (same as /admin/*)". The middleware MUST reject - // unauthenticated requests with 401 before the handler runs; - // without it the handler-side AdminAuthzEvidence default-deny - // is trivially bypassable because the client supplies the - // evidence in the request body. Resolved via app.GetService - // at Init. Empty disables auth (test-only / single-tenant - // dev mode); production deployments MUST set this. - AuthModule string `yaml:"auth_module" json:"auth_module"` - - // ProviderModules lists the iac.provider module names to - // resolve. Each is resolved to an interfaces.IaCProvider via - // app.GetService at Init. - ProviderModules []string `yaml:"provider_modules" json:"provider_modules"` - - // AccessLogPath is the on-disk path for the audit JSONL file. - // Empty disables the audit writer; non-empty opens the writer - // at Init and propagates open errors as a module-init failure - // (FATAL per design Security Review). - AccessLogPath string `yaml:"access_log_path" json:"access_log_path"` - - // AllowUnauthenticated opts into insecure single-tenant mode. - // When false (the default) and AuthModule is empty, Init returns - // an error requiring auth_module. Mutation routes are NEVER - // registered without a real AuthModule; with AllowUnauthenticated:true - // only read routes are active and a prominent warning is logged. - AllowUnauthenticated bool `yaml:"allow_unauthenticated" json:"allow_unauthenticated"` - - // AuthzModule names the authz.casbin (or compatible) module to - // resolve for server-side RBAC on mutation routes. When non-empty, - // infra.admin resolves the module as an Enforcer at Init and calls - // Enforce(subject,"infra:apply"/"infra:destroy"/"infra:read","allow") - // on every request. When empty, authentication is required but RBAC - // is skipped (authn-only single-tenant posture). - AuthzModule string `yaml:"authz_module" json:"authz_module"` -} - -// Enforcer is the server-side RBAC interface satisfied by the -// authz.casbin module wrapper. The variadic extra ...string matches -// the concrete Casbin wrapper's method signature (plan-review C-NEW-1), -// so a non-variadic declaration would not be satisfied by the wrapper. -type Enforcer interface { - Enforce(sub, obj, act string, extra ...string) (bool, error) -} - -// InfraAdmin is the engine-side workflow module. Implements -// modular.Module + the Init/Start/Stop lifecycle hooks. -type InfraAdmin struct { - name string - config InfraAdminConfig - - // Resolved at Init. - app modular.Application - state interfaces.IaCStateStore - providers map[string]interfaces.IaCProvider - providerTypeByModule map[string]string - router *StandardHTTPRouter - secHdrs HTTPMiddleware - auth HTTPMiddleware - authz Enforcer // nil when authz_module not configured - - // T8: in-process desired spec source + per-provider mutexes. - // wfCfg is the WorkflowConfig read at Init; desiredSpecs is the - // set of infra.* resource specs extracted from it. Both are - // passed to PlanResource/ApplyResource handlers so the TOCTOU - // hash is consistent across plan→apply rounds. - wfCfg *config.WorkflowConfig - desiredSpecs []interfaces.ResourceSpec - // providerMu maps provider module name → a mutex for single-flight - // apply/destroy. Pre-populated at Init so the per-provider map - // is read-only at Start/request time (no concurrent write). - providerMu map[string]*sync.Mutex - - // Catalogs are instantiated in-process at Init. - fieldCatalog *catalog.FieldSpecCatalog - regionCatalog *catalog.RegionCatalog - engineCatalog *catalog.EngineCatalog - - // audit is non-nil iff config.AccessLogPath != "" and Open - // succeeded at Init. - audit *audit.Writer - - // Resolved at Start (workflowEngine is registered by engine. - // configureTriggers AFTER app.Init returns). - engine WorkflowEngine -} - -// (The shared WorkflowEngine interface — TriggerWorkflow only — -// already exists in module/http_trigger.go; we reuse it here so -// the package has a single definition.) - -// NewInfraAdmin is the module factory the engine's BuildFromConfig -// dispatches to for `type: infra.admin` entries. T18 registers this -// with the engine via AddModuleType. The factory decodes the loose -// config map into the typed InfraAdminConfig + applies defaults so -// callers can omit fields with sensible fallbacks. -func NewInfraAdmin(name string, cfg map[string]any) modular.Module { - c := InfraAdminConfig{ - RoutePrefix: "/api/infra-admin", - AssetPrefix: "/admin/infra-admin", - } - // Round-trip the loose map through JSON to populate the typed - // struct — uses the same json tags the proto/wfctlhelpers layers - // use elsewhere in the codebase. Map keys not present in the - // struct are silently ignored (e.g. `_config_dir` injected by - // engine.BuildFromConfig at line 612). - if raw, err := json.Marshal(cfg); err == nil { - _ = json.Unmarshal(raw, &c) - } - return &InfraAdmin{ - name: name, - config: c, - providers: map[string]interfaces.IaCProvider{}, - providerMu: map[string]*sync.Mutex{}, - } -} - -// Name returns the host-side module name. -func (m *InfraAdmin) Name() string { return m.name } - -// Dependencies returns the names of modules that MUST initialise -// before this one — the modular framework uses this for its -// init-order DAG. Per design §Module lifecycle: state + http + -// security-headers + every declared provider. -func (m *InfraAdmin) Dependencies() []string { - deps := []string{} - if m.config.StateModule != "" { - deps = append(deps, m.config.StateModule) - } - if m.config.HTTPModule != "" { - deps = append(deps, m.config.HTTPModule) - } - if m.config.SecurityHeadersModule != "" { - deps = append(deps, m.config.SecurityHeadersModule) - } - if m.config.AuthModule != "" { - deps = append(deps, m.config.AuthModule) - } - if m.config.AuthzModule != "" { - deps = append(deps, m.config.AuthzModule) - } - deps = append(deps, m.config.ProviderModules...) - return deps -} - -// RequiresServices declares the same set as Dependencies, but -// shaped for the service-dependency resolver. Both are needed — -// Dependencies drives Init ordering; RequiresServices drives -// service-graph wiring. -// -// NB: workflowEngine is intentionally NOT listed here — it's -// registered by engine.configureTriggers AFTER app.Init returns, -// so listing it as a required service would cause Init to fail -// when modular's resolver runs before the engine has registered -// itself. Resolved at Start instead. Per design line 749-750. -func (m *InfraAdmin) RequiresServices() []modular.ServiceDependency { - deps := []modular.ServiceDependency{} - if m.config.StateModule != "" { - deps = append(deps, modular.ServiceDependency{Name: m.config.StateModule}) - } - if m.config.HTTPModule != "" { - deps = append(deps, modular.ServiceDependency{Name: m.config.HTTPModule}) - } - if m.config.SecurityHeadersModule != "" { - deps = append(deps, modular.ServiceDependency{Name: m.config.SecurityHeadersModule}) - } - if m.config.AuthModule != "" { - deps = append(deps, modular.ServiceDependency{Name: m.config.AuthModule}) - } - if m.config.AuthzModule != "" { - deps = append(deps, modular.ServiceDependency{Name: m.config.AuthzModule}) - } - for _, pm := range m.config.ProviderModules { - deps = append(deps, modular.ServiceDependency{Name: pm}) - } - return deps -} - -// ProvidesServices is nil — this module is a sink, not a source. -// Per design §Module lifecycle: external consumers do not call -// this module via service-graph lookup; they hit it via the HTTP -// routes mounted in Start. -func (m *InfraAdmin) ProvidesServices() []modular.ServiceProvider { return nil } - -// Init resolves the host-side service dependencies + populates the -// providerTypeByModule map + opens the audit writer. -// -// Per design line 749: workflowEngine is NOT resolved here — see -// Start. The intermediate state where the engine isn't yet -// registered is real and intentional; Init must succeed in that -// window so app.Init() returns and configureTriggers can register -// the engine. -func (m *InfraAdmin) Init(app modular.Application) error { - m.app = app - - // T4 (#29): require auth_module unless the operator explicitly - // opted into insecure single-tenant mode. Mutation routes are - // NEVER registered without auth regardless of this flag. - if m.config.AuthModule == "" && !m.config.AllowUnauthenticated { - return fmt.Errorf("infra.admin: auth_module required (set allow_unauthenticated:true to opt into insecure single-tenant mode)") - } - if m.config.AuthModule == "" && m.config.AllowUnauthenticated { - app.Logger().Warn("infra.admin: mutation routes DISABLED (no auth_module); reads only") - } - - // State store. - if m.config.StateModule != "" { - if err := app.GetService(m.config.StateModule, &m.state); err != nil { - return fmt.Errorf("infra.admin: state module %q: %w", m.config.StateModule, err) - } - } - - // HTTP router — type-assert to *StandardHTTPRouter so we can - // call AddRouteWithMiddleware at Start. The interface alone - // (HTTPRouter) doesn't expose middleware-aware route - // registration; the design explicitly requires the typed - // concrete (per §Module lifecycle "ProvidesServices the - // *StandardHTTPRouter typed instance"). We resolve via the - // generic interface{} and then type-assert so the failure - // message is operator-actionable. - if m.config.HTTPModule != "" { - var router any - if err := app.GetService(m.config.HTTPModule, &router); err != nil { - return fmt.Errorf("infra.admin: http module %q: %w", m.config.HTTPModule, err) - } - sr, ok := router.(*StandardHTTPRouter) - if !ok { - return fmt.Errorf("infra.admin: http module %q is %T, need *StandardHTTPRouter", m.config.HTTPModule, router) - } - m.router = sr - } - - // Security headers middleware. - if m.config.SecurityHeadersModule != "" { - var mw any - if err := app.GetService(m.config.SecurityHeadersModule, &mw); err != nil { - return fmt.Errorf("infra.admin: security-headers module %q: %w", m.config.SecurityHeadersModule, err) - } - secMw, ok := mw.(HTTPMiddleware) - if !ok { - return fmt.Errorf("infra.admin: security-headers module %q is %T, need HTTPMiddleware", m.config.SecurityHeadersModule, mw) - } - m.secHdrs = secMw - } - - // Auth middleware — per design §Security Review the - // /api/infra-admin/* and /admin/infra-admin/* routes MUST - // sit behind the host's auth route filter (same as - // /admin/*). Without it, the handler-side AdminAuthzEvidence - // default-deny is bypassable: the client supplies - // {authz_checked, authz_allowed} in the request body, so an - // unauthenticated network actor can send - // {evidence:{authz_checked:true,authz_allowed:true}} and the - // handler accepts it. The auth middleware rejects requests - // without a valid Bearer token at 401 before the handler ever - // runs, closing that gap. - if m.config.AuthModule != "" { - var mw any - if err := app.GetService(m.config.AuthModule, &mw); err != nil { - return fmt.Errorf("infra.admin: auth module %q: %w", m.config.AuthModule, err) - } - authMw, ok := mw.(HTTPMiddleware) - if !ok { - return fmt.Errorf("infra.admin: auth module %q is %T, need HTTPMiddleware", m.config.AuthModule, mw) - } - m.auth = authMw - } - - // Authz enforcer (optional — for server-side write-tier RBAC). - if m.config.AuthzModule != "" { - var authzSvc any - if err := app.GetService(m.config.AuthzModule, &authzSvc); err != nil { - return fmt.Errorf("infra.admin: authz module %q: %w", m.config.AuthzModule, err) - } - enforcer, ok := authzSvc.(Enforcer) - if !ok { - return fmt.Errorf("infra.admin: authz module %q is %T, need Enforcer", m.config.AuthzModule, authzSvc) - } - m.authz = enforcer - } - - // Per-provider IaCProvider handles + single-flight mutexes. - for _, pm := range m.config.ProviderModules { - var p interfaces.IaCProvider - if err := app.GetService(pm, &p); err != nil { - return fmt.Errorf("infra.admin: provider %q: %w", pm, err) - } - m.providers[pm] = p - m.providerMu[pm] = &sync.Mutex{} - } - - // NOTE: providerTypeByModule / wfCfg / desiredSpecs are populated in - // Start(), NOT here. engine.BuildFromConfig registers the "workflow" - // config section AFTER app.Init() (engine.go: app.Init() then - // RegisterConfigSection("workflow")), so app.GetConfigSection("workflow") - // returns "not found" during Init and would silently degrade - // provider_type, supported_regions, and the mutation desiredSpecs to - // empty. Start() runs after BuildFromConfig completes, when the section - // is present. (Surfaced by scenario-92 live boot; unit tests pre-register - // the section via withConfigSectionApp so they did not catch it.) - - // In-process catalogs. - m.fieldCatalog = catalog.New() - m.regionCatalog = catalog.NewRegionCatalog() - m.engineCatalog = catalog.NewEngineCatalog() - - // Audit writer (optional — empty path disables; non-empty path - // MUST succeed per design Security Review). - if m.config.AccessLogPath != "" { - w, err := audit.Open(m.config.AccessLogPath) - if err != nil { - return fmt.Errorf("infra.admin: open audit log %q: %w", m.config.AccessLogPath, err) - } - m.audit = w - } - return nil -} - -// populateProviderTypes walks the loaded WorkflowConfig and captures -// each iac.provider module's config["provider"] string keyed by -// module name. The result feeds handler.ListProviders' provider_type -// + region/engine-catalog-key parameter. -// -// Per spec-reviewer T6 F1: this string is the stable identifier; -// provider.Name() returns the plugin's display name and would fail -// region/engine catalog lookups. -// -// The WorkflowConfig is registered as a config-section under the -// "workflow" name by engine.go:672. Module fall-back (no config -// section) leaves the map empty, which degrades gracefully — -// handler.ListProviders emits per-module entries with empty -// provider_type + empty regions/engines per -// TestListProviders_MissingProviderTypeByModule_DegradesGracefully. -func (m *InfraAdmin) populateProviderTypes(app modular.Application) error { - m.providerTypeByModule = map[string]string{} - - section, err := app.GetConfigSection("workflow") - if err != nil || section == nil { - // Config section missing — graceful degradation per design: - // UI shows empty region/engine dropdowns rather than the - // admin module refusing to start. The `err` is intentionally - // not propagated; the section's absence is a normal state in - // unit-test fakes and during early bootstrap. - return nil //nolint:nilerr // intentional: graceful degradation, see comment - } - wfCfg, ok := section.GetConfig().(*config.WorkflowConfig) - if !ok || wfCfg == nil { - return nil - } - // Store the full config for TOCTOU hash computation. - m.wfCfg = wfCfg - - for i := range wfCfg.Modules { - mod := &wfCfg.Modules[i] - switch { - case mod.Type == "iac.provider": - modCfg := config.ExpandEnvInMap(mod.Config) - pt, _ := modCfg["provider"].(string) - if pt != "" { - m.providerTypeByModule[mod.Name] = pt - } - case isInfraModuleType(mod.Type): - // Extract ResourceSpec from infra.* module. Uses ResolveForEnv - // ("" = default env) to honour per-env overrides and the - // Protected flag — same path as the CLI's resourceSpecFromResolvedModule. - resolved, include := mod.ResolveForEnv("") - if !include { - continue - } - m.desiredSpecs = append(m.desiredSpecs, infraSpecFromResolved(resolved)) - } - } - return nil -} - -// isInfraModuleType returns true for infra.* and platform.* module types -// that represent cloud resources (the set the CLI plans against). -// Mirrors wfctlhelpers.IsInfraType without importing that package. -func isInfraModuleType(t string) bool { - return strings.HasPrefix(t, "infra.") || strings.HasPrefix(t, "platform.") -} - -// infraSpecFromResolved builds an interfaces.ResourceSpec from a -// config.ResolvedModule. Mirrors cmd/wfctl resourceSpecFromResolvedModule. -func infraSpecFromResolved(r *config.ResolvedModule) interfaces.ResourceSpec { - cfg := cloneAnyMap(r.Config) - if r.Protected { - if cfg == nil { - cfg = map[string]any{} - } - cfg["protected"] = true - } - spec := interfaces.ResourceSpec{ - Name: r.Name, - Type: r.Type, - Config: cfg, - DependsOn: extractModuleDependsOn(cfg), // mirrors CLI's extractDependsOn - } - if size, ok := cfg["size"].(string); ok { - spec.Size = interfaces.Size(size) - } - return spec -} - -// extractModuleDependsOn reads the `depends_on` key from a resource config map -// and returns the list of dependency names. Inlined from cmd/wfctl/infra.go -// (package main — not importable) to keep the TOCTOU hash consistent with the -// CLI path. -func extractModuleDependsOn(cfg map[string]any) []string { - if cfg == nil { - return nil - } - raw, ok := cfg["depends_on"] - if !ok { - return nil - } - switch v := raw.(type) { - case []string: - return v - case []any: - out := make([]string, 0, len(v)) - for _, d := range v { - if s, ok := d.(string); ok { - out = append(out, s) - } - } - return out - } - return nil -} - -// cloneAnyMap returns a shallow copy of m (nil-safe). -func cloneAnyMap(m map[string]any) map[string]any { - if m == nil { - return nil - } - out := make(map[string]any, len(m)) - for k, v := range m { - out[k] = v - } - return out -} - -// Start resolves the workflowEngine service (registered after -// app.Init by engine.configureTriggers), mounts the typed API + -// asset routes with the explicit security-headers middleware, and -// fires the three admin-plugin contribution registration pipelines -// via engine.TriggerWorkflow. -// -// Per design line 820-882: the workflowEngine resolution MUST be -// here, not Init. -func (m *InfraAdmin) Start(ctx context.Context) error { - if m.app == nil { - return fmt.Errorf("infra.admin: Start called before Init") - } - if err := m.app.GetService("workflowEngine", &m.engine); err != nil { - return fmt.Errorf("infra.admin: workflowEngine: %w", err) - } - - // Populate providerTypeByModule + wfCfg + desiredSpecs from the loaded - // WorkflowConfig. MUST run here (Start), not Init: the engine registers - // the "workflow" config section after app.Init(), so this would silently - // degrade to empty during Init. Runs before routes serve any request. - if err := m.populateProviderTypes(m.app); err != nil { - return fmt.Errorf("infra.admin: populate provider types: %w", err) - } - - if m.router == nil { - return fmt.Errorf("infra.admin: router unresolved — Init failed silently?") - } - - // Middleware chain: auth FIRST so unauthenticated requests - // short-circuit at 401 before any handler / security-headers - // processing runs. Per design §Security Review + - // AddRouteWithMiddleware contract (http_router.go:228-235): - // middlewares execute in slice order, so [auth, secHdrs] means - // auth wraps secHdrs wraps the handler. - mws := []HTTPMiddleware{} - if m.auth != nil { - mws = append(mws, m.auth) - } - if m.secHdrs != nil { - mws = append(mws, m.secHdrs) - } - - // Typed API routes (reads — no bearer requirement beyond auth middleware). - apiRoutes := []struct { - method string - path string - handler http.HandlerFunc - }{ - {"POST", m.config.RoutePrefix + "/resources", m.handleListResources}, - {"POST", m.config.RoutePrefix + "/resources/{name}", m.handleGetResource}, - {"POST", m.config.RoutePrefix + "/types", m.handleListResourceTypes}, - {"POST", m.config.RoutePrefix + "/providers", m.handleListProviders}, - {"POST", m.config.RoutePrefix + "/generate-config", m.handleGenerateConfig}, - {"GET", m.config.RoutePrefix + "/audit", m.handleAuditTail}, - } - for _, r := range apiRoutes { - adapter := NewHTTPHandlerAdapter(r.handler) - m.router.AddRouteWithMiddleware(r.method, r.path, adapter, mws) - } - - // Mutation routes — only registered when auth is configured. - // requireBearerAuth is added to the middleware chain (innermost - // before the handler) as CSRF protection for state-mutating RPCs. - // Per T4: when m.auth==nil (allow_unauthenticated mode) mutation - // routes are absent; a warning was already logged at Init. - if m.auth != nil { - // T8 F2: warn when multiple providers are configured — the single-flight - // mutex covers only the first declared provider in v1.1; applies to - // provider A will block applies to provider B unnecessarily. - if len(m.config.ProviderModules) > 1 { - m.app.Logger().Warn( - "infra.admin: single-flight mutex covers first provider only in v1.1 — multi-provider configs may see unexpected 409s", - "providers", len(m.config.ProviderModules), - ) - } - requireBearer := requireBearerAuthMiddleware{} - mutMws := append(mws, requireBearer) //nolint:gocritic // intentional append-to-mws copy - mutRoutes := []struct { - method string - path string - handler http.HandlerFunc - }{ - {"POST", m.config.RoutePrefix + "/plan", m.handlePlanResource}, - {"POST", m.config.RoutePrefix + "/apply", m.handleApplyResource}, - {"POST", m.config.RoutePrefix + "/destroy", m.handleDestroyResource}, - {"POST", m.config.RoutePrefix + "/drift", m.handleDriftCheckResource}, - } - for _, r := range mutRoutes { - adapter := NewHTTPHandlerAdapter(r.handler) - m.router.AddRouteWithMiddleware(r.method, r.path, adapter, mutMws) - } - } - - // Asset routes — http.FileServer over the embedded admin.AssetFS. - // fs.Sub strips the leading "ui_dist/" so a request for - // /admin/infra-admin/resources.html (after StripPrefix removes - // /admin/infra-admin) resolves to ui_dist/resources.html inside - // the embed FS. Without the Sub, FileServer would look for - // resources.html at the FS root and 404. - uiSub, err := fs.Sub(admin.AssetFS, "ui_dist") - if err != nil { - return fmt.Errorf("infra.admin: subfs ui_dist: %w", err) - } - assetHandler := http.StripPrefix(m.config.AssetPrefix, http.FileServer(http.FS(uiSub))) - assetAdapter := NewHTTPHandlerAdapter(assetHandler) - m.router.AddRouteWithMiddleware("GET", m.config.AssetPrefix+"/{rest...}", assetAdapter, mws) - - // Admin-plugin contribution registration pipelines. The admin - // plugin defines three pipelines that accept contributions - // (resource list / resource detail / new-resource form); each - // fires once at module Start so the admin dashboard renders - // the entries after the host comes up. - contributions := []struct { - pipelineName string - payload map[string]any - }{ - {"register-infra-admin-resources", map[string]any{ - "module": "admin", - "contribution": map[string]any{ - "id": "infra.resources", - "title": "Infra Resources", - "category": "infra", - "path": m.config.AssetPrefix + "/resources.html", - "render_mode": "iframe", - "permissions": []any{map[string]any{ - "resource": "infra", "action": "read", "permission": "infra:read", - }}, - }, - }}, - {"register-infra-admin-resource-detail", map[string]any{ - "module": "admin", - "contribution": map[string]any{ - "id": "infra.resource-detail", - "title": "Resource Detail", - "category": "infra", - "path": m.config.AssetPrefix + "/resource.html", - "render_mode": "iframe", - "permissions": []any{map[string]any{ - "resource": "infra", "action": "read", "permission": "infra:read", - }}, - }, - }}, - {"register-infra-admin-new-resource", map[string]any{ - "module": "admin", - "contribution": map[string]any{ - "id": "infra.new", - "title": "Draft New Resource", - "category": "infra", - "path": m.config.AssetPrefix + "/new.html", - "render_mode": "iframe", - "permissions": []any{map[string]any{ - "resource": "infra", "action": "read", "permission": "infra:read", - }}, - }, - }}, - // T12: audit-viewer page — read-tier infra:read permission (same as - // other read contributions; audit tail is GET-only, no mutation risk). - {"register-infra-admin-actions", map[string]any{ - "module": "admin", - "contribution": map[string]any{ - "id": "infra.audit", - "title": "Infra Audit Log", - "category": "infra", - "path": m.config.AssetPrefix + "/actions.html", - "render_mode": "iframe", - "permissions": []any{map[string]any{ - "resource": "infra", "action": "read", "permission": "infra:read", - }}, - }, - }}, - } - for _, c := range contributions { - if err := m.engine.TriggerWorkflow(ctx, "pipeline:"+c.pipelineName, "", c.payload); err != nil { - return fmt.Errorf("infra.admin: register contribution via pipeline:%s: %w", c.pipelineName, err) - } - } - return nil -} - -// Stop closes the audit writer (idempotent — double-Stop is a -// no-op because audit.Writer.Close is idempotent). -func (m *InfraAdmin) Stop(_ context.Context) error { - if m.audit != nil { - return m.audit.Close() - } - return nil -} - -// ── HTTP handlers ─────────────────────────────────────────────── - -// marshalOpts is the protojson configuration every handler uses on -// the response path. UseProtoNames=true emits snake_case JSON keys -// matching the proto field names — required by the asset JS pages -// (T10-T12) which access r.provider_module / r.applied_config_json -// / etc. Per spec-reviewer's cross-task contract. -var marshalOpts = protojson.MarshalOptions{UseProtoNames: true} - -// unmarshalOpts is the protojson decode configuration. We allow -// unknown fields so the host can ride out a v1.1 client emitting -// new request fields the handler hasn't seen yet — strict refusal -// would create a backward-compat trap. -var unmarshalOpts = protojson.UnmarshalOptions{DiscardUnknown: true} - -// readAdminBody reads the request body up to a sensible cap (256KB) -// so a pathological client can't OOM the host. AdminListResources -// Input and friends are tiny structs; 256KB is generous headroom. -// Named distinctly from module/api_v1_featureflags.go's readBody -// to avoid the package-level collision. -func readAdminBody(r *http.Request) ([]byte, error) { - const maxBody = 256 * 1024 - return io.ReadAll(io.LimitReader(r.Body, maxBody)) -} - -// auditAccess writes one AdminAuditEntry to the audit log if the -// writer is configured. Errors are logged via stderr (via the -// audit package) but never propagate — the access log is a -// best-effort observability surface, not a request-path -// dependency. -// -// The result string distinguishes outcomes per the proto field's -// semantic intent: "ok" for served requests, "denied" for authz -// refusals (handler's Output.error non-empty). Per spec-reviewer -// T15 F2 (commit 60971783d): hardcoding "ok" hid real denial -// attempts in the access log, defeating the audit log's -// security-review purpose. -// subjectFromRequest extracts the authenticated subject from the -// request context. The auth middleware stores JWT claims as -// map[string]any under authClaimsContextKey; sub is the standard -// JWT claim for the principal. Returns "" when no claims are present -// (e.g. allow_unauthenticated mode or auth middleware not wired). -func (m *InfraAdmin) subjectFromRequest(r *http.Request) string { - claims, ok := r.Context().Value(authClaimsContextKey).(map[string]any) - if !ok || claims == nil { - return "" - } - sub, _ := claims["sub"].(string) - return sub -} - -func (m *InfraAdmin) auditAccess(r *http.Request, action string, ev *adminpb.AdminAuthzEvidence, result string) { - if m.audit == nil { - return - } - subject := "" - if ev != nil { - subject = ev.GetSubject() - } - entry := &audit.Entry{ - TsUnix: nowUnix(), - Subject: subject, - Action: action, - Result: result, - } - _ = m.audit.Write(entry) - _ = r // r reserved for future targets/app_context extraction -} - -// auditResultFor classifies a read-handler output's Error field into the -// three-way audit result: "ok" (no error), "denied" (authz/evidence -// rejection), or "error" (provider/backend failure). Read handlers -// (ListResources, GetResource, etc.) return (output, nil) even on authz -// denial, so the only signal available is the error string. The -// discrimination is substring-based — acceptable here because read -// handlers do not call provider APIs that could return "denied" text. -// Mutation handlers MUST use auditResultFromErr (typed sentinel). -func auditResultFor(errMsg string) string { - if errMsg == "" { - return "ok" - } - // Authz/evidence/TOCTOU rejections contain "authz", "denied", - // "evidence", or "stale" — classify as denied (client mistake). - for _, marker := range []string{"authz", "denied", "evidence", "stale"} { - if strings.Contains(errMsg, marker) { - return "denied" - } - } - // Everything else is a backend or configuration error. - return "error" -} - -// auditResultFromErr classifies a mutation handler's outcome into the -// three-way audit result using the TYPED handler error — NOT -// strings.Contains. This eliminates the false-positive where a provider -// error message containing "denied" (e.g. "provider: access denied to -// cloud API") would be mis-logged as result:"denied" by the substring path. -// -// Classification: -// -// errors.Is(err, handler.ErrAuthzDenied) → "denied" -// outError != "" → "error" -// (both empty/nil) → "ok" -func auditResultFromErr(err error, outError string) string { - if errors.Is(err, handler.ErrAuthzDenied) { - return "denied" - } - if outError != "" { - return "error" - } - return "ok" -} - -// nowUnix is a package-level var so tests can substitute a fixed -// clock without touching time. Default is defaultNowUnix (declared -// in infra_admin_clock.go) → time.Now().UTC().Unix(). -var nowUnix = defaultNowUnix - -func (m *InfraAdmin) handleListResources(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminListResourcesInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - out, _ := handler.ListResources(r.Context(), m.state, m.providers, m.fieldCatalog, &in) - writeProtoMsg(w, out) - m.auditAccess(r, "list_resources", in.GetEvidence(), auditResultFor(out.GetError())) -} - -func (m *InfraAdmin) handleGetResource(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminGetResourceInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - // Route-level path param: /resources/{name}. Falls through to - // body-level Name when path param absent (e.g. tests posting - // directly). - if v := r.PathValue("name"); v != "" { - in.Name = v - } - out, _ := handler.GetResource(r.Context(), m.state, &in) - writeProtoMsg(w, out) - m.auditAccess(r, "get_resource", in.GetEvidence(), auditResultFor(out.GetError())) -} - -func (m *InfraAdmin) handleListResourceTypes(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminListResourceTypesInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - out, _ := handler.ListResourceTypes(r.Context(), m.fieldCatalog, m.providers, &in) - writeProtoMsg(w, out) - m.auditAccess(r, "list_types", in.GetEvidence(), auditResultFor(out.GetError())) -} - -func (m *InfraAdmin) handleListProviders(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminListProvidersInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - out, _ := handler.ListProviders( - r.Context(), - m.providers, - m.providerTypeByModule, - m.fieldCatalog, - m.regionCatalog, - m.engineCatalog, - &in, - ) - writeProtoMsg(w, out) - m.auditAccess(r, "list_providers", in.GetEvidence(), auditResultFor(out.GetError())) -} - -func (m *InfraAdmin) handleGenerateConfig(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminGenerateConfigInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - out, _ := handler.GenerateConfig(r.Context(), m.fieldCatalog, &in) - writeProtoMsg(w, out) - m.auditAccess(r, "generate_config", in.GetEvidence(), auditResultFor(out.GetError())) -} - -// handleAuditTail streams the audit log file as ndjson when the -// audit writer is enabled. Honors `?since=&limit=N` query -// params per design §Security Review row "Access logging": -// "GET /api/infra-admin/audit?since=&limit=N returning -// ndjson". The CLI's `wfctl infra admin audit-tail --since 1h` -// translates the duration to a unix timestamp; the host filters -// by ts_unix > since AND emits at most `limit` entries (0 = no -// limit). Per spec-reviewer T15 F1 (commit 60971783d). -// -// Implementation: scans the file line-by-line via bufio.Scanner -// (1MB max line, same as the CLI decoder), protojson-decodes each -// line to read ts_unix, drops out-of-window entries, forwards the -// rest as ndjson. Lines that fail to decode are skipped silently -// — append-only file may contain partial writes mid-rotation; the -// audit-tail consumer treats those as benign. -// -// Status semantics: opens the file with os.Open BEFORE writing -// any response headers so a missing/permission-denied file -// produces a clean 404 / 500. ServeFile's pre-WriteHeader contract -// is the source of the F3 collision the prior draft had. -func (m *InfraAdmin) handleAuditTail(w http.ResponseWriter, r *http.Request) { - if m.audit == nil { - http.Error(w, "audit log not configured (set access_log_path on infra.admin module)", http.StatusNotFound) - return - } - - // Parse query params. Empty / unparseable values default to 0 - // (no filter / no limit) — matches the design's permissive shape. - q := r.URL.Query() - var sinceUnix int64 - if v := q.Get("since"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - sinceUnix = n - } - } - var limit int - if v := q.Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - limit = n - } - } - - f, err := os.Open(m.config.AccessLogPath) - if err != nil { - // File missing or permission denied — 404 mirrors - // http.ServeFile's IsNotExist branch; 500 covers other - // I/O failures. Body is plain text since the CLI's - // renderAuditTable surfaces the error string verbatim. - if errors.Is(err, fs.ErrNotExist) { - http.Error(w, "audit log file not found", http.StatusNotFound) - return - } - http.Error(w, "open audit log: "+err.Error(), http.StatusInternalServerError) - return - } - defer func() { _ = f.Close() }() - - // Only set headers once we know the file is readable. Header - // + status MUST be set before the first body write, but we - // stream after — clearing F3. - w.Header().Set("Content-Type", "application/x-ndjson") - w.WriteHeader(http.StatusOK) - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - var emitted int - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - // Filter by ts_unix when since is set. Decode just enough - // to read the field; ignore decode errors so partial-write - // lines don't truncate the stream for downstream entries. - if sinceUnix > 0 { - var entry adminpb.AdminAuditEntry - if err := protojson.Unmarshal(line, &entry); err != nil { - continue - } - if entry.GetTsUnix() < sinceUnix { - continue - } - } - // Forward the line as-is so the protojson byte sequence is - // preserved byte-for-byte (the CLI's decoder expects the - // exact wire format the writer emitted, not a re-marshaled - // shape — preserves the int64-as-decimal-string convention). - if _, werr := w.Write(append(line, '\n')); werr != nil { - return - } - emitted++ - if limit > 0 && emitted >= limit { - return - } - } - // Scanner errors mid-stream get swallowed — the client already - // received bytes so we can't change the HTTP status. The next - // audit-tail request will re-attempt. -} - -// writeProtoMsg marshals a proto message via the shared protojson -// MarshalOptions (UseProtoNames=true so snake_case keys match the -// asset JS pages' expectations per the cross-task wire contract). -// On marshal failure, returns 500 with a plain-text body so the -// client always sees an actionable status code. -func writeProtoMsg(w http.ResponseWriter, msg proto.Message) { - data, err := marshalOpts.Marshal(msg) - if err != nil { - http.Error(w, "marshal response: "+err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(data) -} - -// writeStatusProto marshals a proto message and writes it with the given -// HTTP status code. On marshal failure, falls back to plain-text 500. -func writeStatusProto(w http.ResponseWriter, status int, msg proto.Message) { - data, err := marshalOpts.Marshal(msg) - if err != nil { - http.Error(w, "marshal response: "+err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _, _ = w.Write(data) -} - -// writeMutationResponse writes a mutation handler's output using a typed -// HTTP status discriminator: -// -// - handler.ErrAuthzDenied → HTTP 403 (typed sentinel; avoids strings.Contains -// false-positives when a provider error message happens to contain "denied") -// - non-empty Output.error (provider / backend failure) → HTTP 500 -// - success → HTTP 200 (via writeProtoMsg) -// -// Using this for plan/apply/destroy replaces the naive writeProtoMsg(w, out) -// pattern that silently returned 200 for all outcomes (Bug 3 + Bug 4 fix). -func writeMutationResponse(w http.ResponseWriter, msg outputError, err error) { - if errors.Is(err, handler.ErrAuthzDenied) { - writeStatusProto(w, http.StatusForbidden, msg) - return - } - if msg.GetError() != "" { - writeStatusProto(w, http.StatusInternalServerError, msg) - return - } - writeProtoMsg(w, msg) -} - -// ── T8: requireBearerAuth middleware ───────────────────────────────────────── - -// requireBearerAuthMiddleware is an HTTPMiddleware that rejects requests -// lacking an Authorization: Bearer header with 401. It is applied -// to mutation routes only (plan/apply/destroy/drift) as a CSRF guard. -// It does NOT validate the token — the outer auth middleware (m.auth) has -// already done so; this gate only checks the header form to prevent -// cookie-based CSRF forgeries against mutation routes. -type requireBearerAuthMiddleware struct{} - -func (requireBearerAuthMiddleware) Process(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || len(auth) <= len("Bearer ") { - http.Error(w, "mutation routes require Authorization: Bearer ", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -// ── T8: mutation route handlers ────────────────────────────────────────────── - -// tryLockProvider attempts to acquire the per-provider mutex. Returns a -// release func and true on success; 409 on the wire + false when already locked. -func (m *InfraAdmin) tryLockProvider(w http.ResponseWriter) (release func(), ok bool) { - // Select the first provider's mutex (single-provider model for v1.1). - var mu *sync.Mutex - for _, pm := range m.config.ProviderModules { - if mu2, exists := m.providerMu[pm]; exists { - mu = mu2 - break - } - } - if mu == nil { - return func() {}, true // no mutex → no contention guard needed - } - if !mu.TryLock() { - http.Error(w, `{"error":"apply in progress — retry later"}`, http.StatusConflict) - return nil, false - } - return func() { mu.Unlock() }, true -} - -func (m *InfraAdmin) handlePlanResource(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminPlanInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - // Server-side RBAC: plan is infra:apply-gated — viewers must not be - // able to probe the desired-state hash or action list (Bug 4 fix). - subject := m.subjectFromRequest(r) - if m.authz != nil { - ok, enforceErr := m.authz.Enforce(subject, "infra:apply", "allow") - if enforceErr != nil { - // Route through writeStatusProto so the 500 body is proto-JSON, - // consistent with all other mutation error responses (Finding 1). - writeStatusProto(w, http.StatusInternalServerError, &adminpb.AdminPlanOutput{Error: "plan: authz enforce error"}) - m.auditAccess(r, "plan", in.GetEvidence(), "error") - return - } - if !ok { - // Generic denial — do NOT reflect the authenticated subject in the - // response body (Finding 2). Subject is captured in the audit log - // separately. Route through writeMutationResponse for proto-JSON body. - writeMutationResponse(w, &adminpb.AdminPlanOutput{Error: "plan: infra:apply denied"}, handler.ErrAuthzDenied) - m.auditAccess(r, "plan", in.GetEvidence(), "denied") - return - } - } - out, handlerErr := handler.PlanResource(r.Context(), m.state, m.providers, m.wfCfg, m.desiredSpecs, &in) - writeMutationResponse(w, out, handlerErr) - m.auditAccess(r, "plan", in.GetEvidence(), auditResultFromErr(handlerErr, out.GetError())) -} - -func (m *InfraAdmin) handleApplyResource(w http.ResponseWriter, r *http.Request) { - release, ok := m.tryLockProvider(w) - if !ok { - return - } - defer release() - - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminApplyInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - subject := m.subjectFromRequest(r) - out, handlerErr := handler.ApplyResource(r.Context(), m.state, m.providers, m.authz, subject, m.wfCfg, m.desiredSpecs, &in) - writeMutationResponse(w, out, handlerErr) - m.auditAccess(r, "apply", in.GetEvidence(), auditResultFromErr(handlerErr, out.GetError())) -} - -func (m *InfraAdmin) handleDestroyResource(w http.ResponseWriter, r *http.Request) { - release, ok := m.tryLockProvider(w) - if !ok { - return - } - defer release() - - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminDestroyInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - subject := m.subjectFromRequest(r) - out, handlerErr := handler.DestroyResource(r.Context(), m.providers, m.authz, subject, &in) - writeMutationResponse(w, out, handlerErr) - m.auditAccess(r, "destroy", in.GetEvidence(), auditResultFromErr(handlerErr, out.GetError())) -} - -func (m *InfraAdmin) handleDriftCheckResource(w http.ResponseWriter, r *http.Request) { - body, err := readAdminBody(r) - if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) - return - } - var in adminpb.AdminDriftInput - if len(body) > 0 { - if err := unmarshalOpts.Unmarshal(body, &in); err != nil { - http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) - return - } - } - out, _ := handler.DriftCheckResource(r.Context(), m.providers, &in) //nolint:errcheck // errors go to out.Error - writeProtoMsg(w, out) - m.auditAccess(r, "drift", in.GetEvidence(), auditResultFor(out.GetError())) -} diff --git a/module/infra_admin_clock.go b/module/infra_admin_clock.go deleted file mode 100644 index eb8a441c..00000000 --- a/module/infra_admin_clock.go +++ /dev/null @@ -1,9 +0,0 @@ -package module - -import "time" - -// defaultNowUnix is the production clock for the audit subsystem. -// Factored out so tests can override `nowUnix` (declared as a var -// in infra_admin.go) with a fixed-clock fake without pulling time -// into the test surface. -func defaultNowUnix() int64 { return time.Now().UTC().Unix() } diff --git a/module/infra_admin_integration_test.go b/module/infra_admin_integration_test.go deleted file mode 100644 index 05b830c6..00000000 --- a/module/infra_admin_integration_test.go +++ /dev/null @@ -1,623 +0,0 @@ -// Integration test for the host-side infra.admin module exercising -// a real workflow engine boot + live workflow-plugin-admin gRPC -// plugin subprocess. Per docs/plans/2026-05-27-infra-admin-dynamic.md -// Task 17 + design §Multi-Component Validation row "Module -// integration test": -// -// "Boot mini workflow app with admin.dashboard (plugin form) + -// infra.admin + stub provider in-process; manually run -// step.admin_register_contribution for the three pages; verify -// contributions appear in GET /api/admin/contributions; verify -// /api/infra-admin/resources returns 200." -// -// Test structure (lighter harness — per team-lead's option-4 -// directive 2026-05-27, bypasses engine.BuildFromConfig + its -// auto-inject hook so the admin plugin's ConfigTransformHook -// doesn't drag in its full auxiliary stack): -// -// 1. Probe for the sibling workflow-plugin-admin repo + build -// its binary into the runtime layout the external-plugin -// loader expects (path/plugin.json + path/binary). -// 2. Boot a real *workflow.StdEngine via NewStdEngine + load all -// built-in engine plugins via pluginall.LoadAll(engine) so -// the engine's module/step/trigger factory maps are -// populated. -// 3. Load the external workflow-plugin-admin via -// external.NewExternalPluginManager → DiscoverPlugins → -// LoadPlugin → engine.LoadPlugin(adapter). Adapter -// registers admin.dashboard module factory + the 4 admin -// step factories into the engine's registries. -// 4. Manually construct + register each module the assertions -// need: admin.dashboard (via the loaded plugin's factory), -// http.router, security-headers, iac.state (memory), -// infra.admin. No BuildFromConfig, no ConfigTransformHook -// auto-inject. -// 5. app.Init() once, then call Start on the router + infra.admin -// so routes mount. -// 6. Assert: -// (a) Manual Init + Start succeeded. -// (b) Live admin plugin subprocess registers 3 contributions -// via 3 step.admin_register_contribution invocations and -// step.admin_list_contributions reads them back. -// (c) httptest POST /api/infra-admin/resources returns 200 + -// valid AdminListResourcesOutput protojson against the -// live infra.admin router. -// (d) httptest GET /admin/infra-admin/resources.html returns -// 200 + text/html with the embedded body. -// -// Skip conditions (each surfaces a distinct cause string so CI -// failure modes are unambiguous): -// - testing.Short() — fast-path skip for tight CI sweeps. -// - sibling workflow-plugin-admin repo absent — plan T17 -// graceful-degradation per design §Personas. -// - plugin build failure — pure-unit-test env. -// - LoadPlugin fails — plugin may need a newer engine ABI. -// - admin.dashboard factory not exposed by adapter — plugin -// subprocess didn't publish module types (gRPC handshake gap). - -package module_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/GoCodeAlone/modular" - workflow "github.com/GoCodeAlone/workflow" - adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" - "github.com/GoCodeAlone/workflow/interfaces" - "github.com/GoCodeAlone/workflow/module" - pluginexternal "github.com/GoCodeAlone/workflow/plugin/external" - pluginall "github.com/GoCodeAlone/workflow/plugins/all" - "google.golang.org/protobuf/encoding/protojson" -) - -// integrationLogger is the minimum modular.Logger the engine needs -// to boot. We discard outputs by default; tests that want to debug -// boot can swap the implementation. -type integrationLogger struct{} - -func (integrationLogger) Debug(string, ...any) {} -func (integrationLogger) Info(string, ...any) {} -func (integrationLogger) Warn(string, ...any) {} -func (integrationLogger) Error(string, ...any) {} - -// TestInfraAdmin_IntegrationWithLiveAdminPlugin boots a real -// engine with the workflow-plugin-admin external subprocess + -// infra.admin module wired manually (lighter harness — no -// BuildFromConfig), then asserts the 4 plan §Step 1-N integration -// boundary properties. -func TestInfraAdmin_IntegrationWithLiveAdminPlugin(t *testing.T) { - if testing.Short() { - t.Skip("integration: -short flag set; skipping plugin-binary build + engine boot") - } - - // ── 1. Probe + build the workflow-plugin-admin binary ────── - pluginRepoCandidates := []string{ - os.Getenv("WORKFLOW_PLUGIN_ADMIN_PATH"), - "../../workflow-plugin-admin", - "../workflow-plugin-admin", - } - var pluginRepoPath string - for _, p := range pluginRepoCandidates { - if p == "" { - continue - } - if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil { - pluginRepoPath = p - break - } - } - if pluginRepoPath == "" { - t.Skip("workflow-plugin-admin repo not found on disk (sibling or WORKFLOW_PLUGIN_ADMIN_PATH); skipping per plan T17 graceful-degradation path") - } - - tmpDir := t.TempDir() - pluginDir := filepath.Join(tmpDir, "plugins", "workflow-plugin-admin") - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - binPath := filepath.Join(pluginDir, "workflow-plugin-admin") - if runtime.GOOS == "windows" { - binPath += ".exe" - } - build := exec.Command("go", "build", "-o", binPath, "./cmd/workflow-plugin-admin") - build.Dir = pluginRepoPath - build.Env = append(os.Environ(), "GOWORK=off", "CGO_ENABLED=0") - if out, err := build.CombinedOutput(); err != nil { - t.Skipf("admin plugin build failed (expected in pure-unit-test envs): %v\n%s", err, out) - } - if info, err := os.Stat(binPath); err != nil { - t.Fatalf("plugin binary not at expected layout %s: %v", binPath, err) - } else if info.Mode().Perm()&0o111 == 0 { - t.Fatalf("plugin binary at %s is not executable", binPath) - } - manifest, err := os.ReadFile(filepath.Join(pluginRepoPath, "plugin.json")) - if err != nil { - t.Fatalf("read plugin.json: %v", err) - } - if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifest, 0o600); err != nil { - t.Fatal(err) - } - pluginsDir := filepath.Join(tmpDir, "plugins") - t.Setenv("WFCTL_PLUGIN_DIR", pluginsDir) - - // ── 2. Boot a fresh app + engine + load built-in plugins ─── - // - // We intentionally DEFER app.Init() until step 5 — modular - // only allows one Init pass and all modules must be - // registered first. NewStdEngine doesn't call Init itself. - app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), integrationLogger{}) - engine := workflow.NewStdEngine(app, integrationLogger{}) - - // pluginall.LoadAll mirrors cmd/server's production boot - // path and populates engine.moduleFactories / - // engine.stepRegistry for every built-in plugin type - // (http.router, http.middleware.securityheaders, iac.state, - // step.json_response, etc.). We don't depend on every - // factory below — but we do depend on http.router + - // security-headers + iac.state to construct the infra.admin - // dependency surface, so loading them all is the simplest - // way to keep the test aligned with what the engine - // actually exposes in production. - if err := pluginall.LoadAll(engine); err != nil { - t.Fatalf("pluginall.LoadAll: %v", err) - } - - // ── 3. Load the external workflow-plugin-admin subprocess ── - extMgr := pluginexternal.NewExternalPluginManager(pluginsDir, nil) - discovered, derr := extMgr.DiscoverPlugins() - if derr != nil { - t.Fatalf("DiscoverPlugins: %v", derr) - } - if len(discovered) == 0 { - t.Fatalf("DiscoverPlugins found 0 plugins in %s — layout wrong?", pluginsDir) - } - t.Cleanup(func() { extMgr.Shutdown() }) - for _, name := range discovered { - adapter, lerr := extMgr.LoadPlugin(name) - if lerr != nil { - t.Skipf("LoadPlugin(%s) failed: %v — workflow-plugin-admin may need a newer engine ABI; defer to PR-2 scenario harness for full validation", name, lerr) - } - if err := engine.LoadPlugin(adapter); err != nil { - t.Fatalf("engine.LoadPlugin(%s): %v", name, err) - } - } - - // ── 4. Manually construct + register modules ────────────── - // - // Skip BuildFromConfig + its ConfigTransformHook. Manually - // wire the minimum surface area the 4 assertions need: an - // http router for the route adapter, a security-headers - // middleware for the route-wrap path, an iac.state backend - // (memory) for ListResources, the admin.dashboard module - // from the live plugin subprocess for the contribution - // registry, and infra.admin itself. - loader := engine.PluginLoader() - moduleFactories := loader.ModuleFactories() - adminFactory, ok := moduleFactories["admin.dashboard"] - if !ok { - t.Skipf("admin.dashboard module factory not published by the plugin adapter (loaded plugins: %v) — plugin gRPC handshake may not have completed; defer to PR-2 scenario harness", discovered) - } - adminMod := adminFactory("admin", map[string]any{ - "route_prefix": "/admin", - "app_name": "Integration Test", - }) - - httpRouter := module.NewStandardHTTPRouter("http-router") - secHdrs := module.NewSecurityHeadersMiddleware( - "security-headers", - module.SecurityHeadersConfig{ - FrameOptions: "SAMEORIGIN", - ContentSecurityPolicy: "default-src 'self'", - }, - ) - // interfaces.IaCStateStore is the ResourceState-based contract - // the InfraAdmin module expects — distinct from the legacy - // IaCState interface that module.NewIaCModule wraps. Workflow - // core ships no built-in ResourceState backend (those land - // over gRPC from workflow-plugin-infra). Register a stub - // module that provides the service under the expected name - // so modular's Dependencies() resolver finds a real module - // AND the service registry has an iac-state entry. - iacStateModule := &integrationStateStubModule{name: "iac-state"} - - // Auth middleware — design §Security Review requires every - // /api/infra-admin/* and /admin/infra-admin/* request to sit - // behind a route-filter auth layer. Wire a Bearer-token auth - // stub that accepts the test-token below. Assertions (c) + - // (d) supply the matching Bearer header. - authStub := &integrationAuthStubModule{name: "auth", validToken: "integration-test-token"} - - auditPath := filepath.Join(tmpDir, "audit.jsonl") - infraAdmin := module.NewInfraAdmin("infra-admin", map[string]any{ - "route_prefix": "/api/infra-admin", - "asset_prefix": "/admin/infra-admin", - "state_module": "iac-state", - "http_module": "http-router", - "security_headers_module": "security-headers", - "auth_module": "auth", - "provider_modules": []string{}, - "access_log_path": auditPath, - }) - - app.RegisterModule(adminMod) - app.RegisterModule(httpRouter) - app.RegisterModule(secHdrs) - app.RegisterModule(iacStateModule) - app.RegisterModule(authStub) - app.RegisterModule(infraAdmin) - - // ── 5. Single Init pass ─────────────────────────────────── - if err := app.Init(); err != nil { - // Surface manual-wiring failures with full evidence — - // these mean the admin plugin (or one of the built-in - // modules above) depends on an additional service / - // config-section that the lighter harness doesn't - // provide. PR-2 docker-compose harness owns the full - // chain integration. - t.Skipf("manual app.Init failed (likely transitive service dep gap): %v — PR-2 workflow-scenarios/92-infra-admin-demo covers full chain via docker-compose", err) - } - t.Log("assertion (a): manual module wiring + app.Init succeeded") - - // Modular's Constructor pattern on *StandardHTTPRouter creates - // a fresh instance during injectServices (http_router.go:76-93) - // — the original `httpRouter` pointer we registered is NOT the - // one that lands in the service registry. Re-resolve the live - // router from the registry so route assertions hit the same - // instance infra.admin's Start mounts routes onto. - var liveRouterAny any - if err := app.GetService("http-router", &liveRouterAny); err != nil { - t.Fatalf("GetService(http-router): %v", err) - } - liveRouter, ok := liveRouterAny.(*module.StandardHTTPRouter) - if !ok { - t.Fatalf("http-router service is %T, want *module.StandardHTTPRouter", liveRouterAny) - } - - ctx := context.Background() - // Register a no-op WorkflowEngine service. Production wires - // this in engine.configureTriggers AFTER app.Init; the - // infra.admin module's Start resolves it then. Per design - // line 749, the deferred lookup matches that ordering. The - // stub records nothing — the assertion (b) flow exercises - // the live admin plugin subprocess via direct step - // invocation, not via this engine path. - if err := app.RegisterService("workflowEngine", noopWorkflowEngine{}); err != nil { - t.Fatalf("RegisterService(workflowEngine): %v", err) - } - infraStartable, ok := infraAdmin.(modular.Startable) - if !ok { - t.Fatal("infraAdmin module does not implement modular.Startable") - } - if err := infraStartable.Start(ctx); err != nil { - t.Fatalf("infraAdmin.Start: %v", err) - } - // Router builds its mux at Start — call AFTER infra.admin so - // the routes infra.admin registered via AddRouteWithMiddleware - // land in the freshly built mux. Mirrors the unit-test - // ordering in TestInfraAdmin_Start_MountsRoutesWithMiddleware. - if err := liveRouter.Start(ctx); err != nil { - t.Fatalf("liveRouter.Start: %v", err) - } - t.Cleanup(func() { - if s, ok := adminMod.(modular.Stoppable); ok { - _ = s.Stop(ctx) - } - }) - t.Cleanup(func() { - if s, ok := infraAdmin.(modular.Stoppable); ok { - _ = s.Stop(ctx) - } - }) - - // ── 6a. Assertion (b): manual step.admin_register_contribution ─ - // - // Per design line: "manually run step.admin_register_contribution - // for the three pages". Each call lands the contribution in the - // admin.dashboard module's in-subprocess registry; the matching - // step.admin_list_contributions then reads it back. Both step - // factories are gRPC proxies into the live workflow-plugin- - // admin subprocess, so this exercises the real cross-process - // contribution flow without depending on the HTTP pipeline - // auto-inject path. - stepRegistry := engine.GetStepRegistry() - contributions := []struct { - ID string - Title string - Path string - }{ - {"infra.resources", "Resources", "/admin/infra-admin/resources.html"}, - {"infra.resource-detail", "Resource Detail", "/admin/infra-admin/resource.html"}, - {"infra.new", "New Resource", "/admin/infra-admin/new.html"}, - } - for _, c := range contributions { - // AdminStepConfig (the step's strict-proto config message) - // has a single field: module. Contribution payload travels - // in pc.Current via RegisterContributionInput.contribution - // per the typed step contract — wire keys are snake_case, - // per cross-task contract UseProtoNames=true. - step, err := stepRegistry.Create( - "step.admin_register_contribution", - "register-"+c.ID, - map[string]any{"module": "admin"}, - app, - ) - if err != nil { - t.Fatalf("stepRegistry.Create(step.admin_register_contribution, %s): %v", c.ID, err) - } - pc := interfaces.NewPipelineContext(map[string]any{ - "module": "admin", - "contribution": map[string]any{ - "id": c.ID, - "title": c.Title, - "path": c.Path, - "app_context": "infra", - }, - }, map[string]any{}) - if _, err := step.Execute(ctx, pc); err != nil { - t.Fatalf("step.admin_register_contribution(%s).Execute: %v", c.ID, err) - } - } - - // Invoke step.admin_list_contributions, assert all 3 land. - listStep, err := stepRegistry.Create("step.admin_list_contributions", "list", map[string]any{"module": "admin"}, app) - if err != nil { - t.Fatalf("stepRegistry.Create(step.admin_list_contributions): %v", err) - } - listPC := interfaces.NewPipelineContext(map[string]any{"module": "admin"}, map[string]any{}) - listRes, err := listStep.Execute(ctx, listPC) - if err != nil { - t.Fatalf("step.admin_list_contributions.Execute: %v", err) - } - got := extractContributionIDs(t, listRes.Output) - for _, c := range contributions { - if _, ok := got[c.ID]; !ok { - t.Errorf("assertion (b): contribution %q missing in list_contributions output (got %v)", c.ID, keys(got)) - } - } - if !t.Failed() { - t.Log("assertion (b): 3 contributions registered + listed back via live admin plugin subprocess step factories") - } - - // Sanity-probe: verify the route landed in the live router. - if !liveRouter.HasRoute("POST", "/api/infra-admin/resources") { - t.Errorf("HasRoute(POST /api/infra-admin/resources) = false on live router; infra.admin.Start did not register its routes") - } - - // ── 6b. Assertion (c): POST /api/infra-admin/resources ─── - listResReq := &adminpb.AdminListResourcesInput{ - Evidence: &adminpb.AdminAuthzEvidence{ - AuthzChecked: true, AuthzAllowed: true, - Subject: "integration-test", - }, - } - listResBody, err := protojson.Marshal(listResReq) - if err != nil { - t.Fatalf("marshal AdminListResourcesInput: %v", err) - } - postReq := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", bytes.NewReader(listResBody)) - postReq.Header.Set("Content-Type", "application/json") - postReq.Header.Set("Authorization", "Bearer integration-test-token") - postRec := httptest.NewRecorder() - liveRouter.ServeHTTP(postRec, postReq) - if postRec.Code != http.StatusOK { - t.Errorf("POST /api/infra-admin/resources status = %d, want 200; body=%s", postRec.Code, postRec.Body.String()) - } else { - var out adminpb.AdminListResourcesOutput - if err := protojson.Unmarshal(postRec.Body.Bytes(), &out); err != nil { - t.Errorf("assertion (c): response not valid AdminListResourcesOutput protojson: %v\n%s", err, postRec.Body.String()) - } else { - t.Log("assertion (c): POST /api/infra-admin/resources returned 200 + valid AdminListResourcesOutput protojson") - } - } - - // ── 6c. Assertion (d): GET /admin/infra-admin/resources.html ─ - assetReq := httptest.NewRequest(http.MethodGet, "/admin/infra-admin/resources.html", nil) - assetReq.Header.Set("Authorization", "Bearer integration-test-token") - assetRec := httptest.NewRecorder() - liveRouter.ServeHTTP(assetRec, assetReq) - if assetRec.Code != http.StatusOK { - t.Errorf("GET asset status = %d, want 200; body=%s", assetRec.Code, assetRec.Body.String()) - } else { - ct := assetRec.Header().Get("Content-Type") - if !strings.Contains(ct, "html") { - t.Errorf("assertion (d): asset Content-Type = %q, want text/html", ct) - } - bodyLower := strings.ToLower(assetRec.Body.String()) - if !strings.Contains(bodyLower, "` query-param contract — the CLI's -// `wfctl infra admin audit-tail --since 1h` depends on the host -// filtering by timestamp; without this, --since is silently a -// no-op. Per spec-reviewer T15 F1 (commit 60971783d). -// -// Writes 3 audit entries with staggered timestamps, then queries -// the audit endpoint with since= and asserts -// only the 2 newest entries are returned. -func TestInfraAdmin_AuditTail_FiltersBySince(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - dir := t.TempDir() - cfg := standardCfg() - cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - // Stamp three entries with explicit ts_unix values so we can - // assert filtering deterministically. Use module's audit - // writer directly so the file is in the exact protojson - // shape the handler reads. - t0 := int64(1716800000) - t1 := int64(1716800100) - t2 := int64(1716800200) - for _, ts := range []int64{t0, t1, t2} { - entry := &adminpb.AdminAuditEntry{ - TsUnix: ts, - Subject: fmt.Sprintf("user:t-%d", ts), - Action: "list_resources", - Result: "ok", - } - if err := m.audit.Write(entry); err != nil { - t.Fatal(err) - } - } - - // Filter with since=t1 → expect t1 + t2 (entries with - // ts_unix < t1 dropped). The handler uses < not <= on the - // since threshold per design ("entries newer than this - // duration" — strict). - req := httptest.NewRequest(http.MethodGet, - fmt.Sprintf("/api/infra-admin/audit?since=%d", t1), nil) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - if rec.Header().Get("Content-Type") != "application/x-ndjson" { - t.Errorf("Content-Type = %q, want application/x-ndjson", rec.Header().Get("Content-Type")) - } - lines := strings.Split(strings.TrimRight(rec.Body.String(), "\n"), "\n") - if len(lines) != 2 { - t.Fatalf("filtered ndjson has %d lines, want 2 (entries at t1 + t2)\n%s", len(lines), rec.Body.String()) - } - // Decode each and verify the timestamps match what we expect. - var got []int64 - for _, line := range lines { - var entry adminpb.AdminAuditEntry - if err := protojson.Unmarshal([]byte(line), &entry); err != nil { - t.Fatalf("decode line: %v\n%s", err, line) - } - got = append(got, entry.GetTsUnix()) - } - if got[0] != t1 || got[1] != t2 { - t.Errorf("got ts_unix sequence %v, want [%d, %d]", got, t1, t2) - } -} - -// TestInfraAdmin_AuditTail_FiltersByLimit pins the `?limit=N` -// query-param contract — caller can cap the number of returned -// entries to avoid response-size explosions on long logs. -func TestInfraAdmin_AuditTail_FiltersByLimit(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - dir := t.TempDir() - cfg := standardCfg() - cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - for i := range 5 { - entry := &adminpb.AdminAuditEntry{ - TsUnix: int64(1716800000 + i*10), - Subject: fmt.Sprintf("user:%d", i), - Action: "list_resources", - Result: "ok", - } - if err := m.audit.Write(entry); err != nil { - t.Fatal(err) - } - } - - req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit?limit=2", nil) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - lines := strings.Split(strings.TrimRight(rec.Body.String(), "\n"), "\n") - if len(lines) != 2 { - t.Errorf("got %d lines with limit=2, want 2\n%s", len(lines), rec.Body.String()) - } -} - -// TestInfraAdmin_AuditTail_NoFilterReturnsAll verifies the -// no-query-param case (CLI invoked without --since / --limit) -// returns every line. -func TestInfraAdmin_AuditTail_NoFilterReturnsAll(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - dir := t.TempDir() - cfg := standardCfg() - cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - for i := range 3 { - if err := m.audit.Write(&adminpb.AdminAuditEntry{TsUnix: int64(1716800000 + i*10), Action: "list_resources", Result: "ok"}); err != nil { - t.Fatal(err) - } - } - - req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit", nil) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - lines := strings.Split(strings.TrimRight(rec.Body.String(), "\n"), "\n") - if len(lines) != 3 { - t.Errorf("got %d lines without filters, want 3", len(lines)) - } -} - -// TestInfraAdmin_AuditTail_FileMissingReturns404 pins the F3 -// status-code fix — file open failure surfaces as a clean 404 -// rather than a 200-with-error-body. The earlier draft pre-set -// the 200 status before http.ServeFile, which then surfaced -// "404 page not found" as body content with status 200. -func TestInfraAdmin_AuditTail_FileMissingReturns404(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - dir := t.TempDir() - cfg := standardCfg() - cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - // Delete the audit file AFTER Init creates it so the open - // fails at handler-time. - if err := os.Remove(cfg.AccessLogPath); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit", nil) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404 (file missing); body=%s", rec.Code, rec.Body.String()) - } -} - -// TestInfraAdmin_AuditTail_NotConfiguredReturns404 pins the -// "audit log not configured" 404 path — distinct from the -// file-missing 404 (different operator diagnosis). -func TestInfraAdmin_AuditTail_NotConfiguredReturns404(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - cfg := standardCfg() - // cfg.AccessLogPath left empty — audit writer not opened. - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit", nil) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404 (not configured)", rec.Code) - } - if !strings.Contains(rec.Body.String(), "not configured") { - t.Errorf("expected 'not configured' in body, got %q", rec.Body.String()) - } -} - -// TestInfraAdmin_AuditAccess_RecordsDeniedResult pins F2: -// authz-denied requests MUST log result="denied", not "ok". -// Otherwise security-event review hides actual denial attempts. -func TestInfraAdmin_AuditAccess_RecordsDeniedResult(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - dir := t.TempDir() - cfg := standardCfg() - cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - // Send a request WITHOUT evidence — handler library rejects - // with default-deny → out.Error non-empty → audit should - // record result="denied". - body := []byte(`{}`) - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", bytes.NewReader(body)) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200 (authz refusal surfaces via Output.error per tag-100)", rec.Code) - } - - // Read the audit log + assert the recorded result is "denied". - data, err := os.ReadFile(cfg.AccessLogPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"result":"denied"`) { - t.Errorf("audit log missing denied result for refused request:\n%s", string(data)) - } - if strings.Contains(string(data), `"result":"ok"`) { - t.Errorf("audit log records 'ok' for a denied request — F2 regression:\n%s", string(data)) - } -} - -// TestInfraAdmin_AuditAccess_RecordsOkResult is the positive -// counterpart — happy-path requests log result="ok". -func TestInfraAdmin_AuditAccess_RecordsOkResult(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - dir := t.TempDir() - cfg := standardCfg() - cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - body := []byte(`{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"user:alice"}}`) - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", bytes.NewReader(body)) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - - data, err := os.ReadFile(cfg.AccessLogPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"result":"ok"`) { - t.Errorf("audit log missing 'ok' result for happy-path request:\n%s", string(data)) - } -} - -// authMwStub is a Bearer-token HTTPMiddleware used by the T15 -// auth-route-filter regression tests. It rejects every request -// missing an `Authorization: Bearer …` header with 401 BEFORE the -// handler runs — mirrors module.AuthMiddleware's production -// behaviour for the route-filter contract that closes the -// AdminAuthzEvidence-spoofing gap (design §Security Review). -type authMwStub struct { - name string - // validToken, when non-empty, is the only Bearer token accepted. - // Empty string means "any non-empty Bearer token passes" (matches - // AuthMiddleware default-provider behaviour when no provider - // matches the token). - validToken string -} - -func (s *authMwStub) Process(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Authorization header required", http.StatusUnauthorized) - return - } - token := strings.TrimPrefix(authHeader, "Bearer ") - if s.validToken != "" && token != s.validToken { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -// newAuthEnabledApp builds a test app with the standard T15 -// dependency surface plus an auth.jwt-shaped HTTPMiddleware -// registered under "auth". Pairs with standardAuthCfg() below. -func newAuthEnabledApp(t *testing.T, providerType string) (*withConfigSectionApp, *recordingEngine, *stateStoreStub, *authMwStub) { - t.Helper() - app, engine, store := newAppWithWorkflowSection(t, providerType) - auth := &authMwStub{name: "auth"} - _ = app.RegisterService("auth", auth) - return app, engine, store, auth -} - -// standardAuthCfg returns the design's reference config WITH the -// auth_module field set — the production shape per §Security -// Review. Mirrors the YAML the workflow-scenarios/92 config will -// declare in PR-2. -func standardAuthCfg() InfraAdminConfig { - c := standardCfg() - c.AuthModule = "auth" - return c -} - -// TestInfraAdmin_UnauthenticatedRequest_Returns401 — design §Security -// Review regression: a request with NO Bearer token MUST be rejected -// with 401 BEFORE the handler runs. Without the auth middleware in -// front of the route, the handler-side AdminAuthzEvidence -// default-deny gives 403 with a body — which is still a leak compared -// to the explicit auth-first 401 contract. -func TestInfraAdmin_UnauthenticatedRequest_Returns401(t *testing.T) { - app, _, _, _ := newAuthEnabledApp(t, "digitalocean") - m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - // No Authorization header at all. - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", - bytes.NewReader([]byte(`{}`))) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Errorf("unauthenticated request: status = %d, want 401; body=%s", rec.Code, rec.Body.String()) - } -} - -// TestInfraAdmin_ClientCannotSpoofAuthzEvidence — design §Security -// Review hard contract: a client supplying -// {evidence:{authz_checked:true,authz_allowed:true}} in the request -// body with NO Authorization header MUST still get 401. Without the -// auth route filter, this spoofing trivially bypasses the -// handler-side default-deny. With the auth filter, the request -// never reaches the handler. -// -// This test is the explicit regression gate for the security gap -// team-lead identified during PR-1 review: AdminAuthzEvidence is -// client-supplied data; the host MUST authenticate before -// believing it. -func TestInfraAdmin_ClientCannotSpoofAuthzEvidence(t *testing.T) { - app, _, _, _ := newAuthEnabledApp(t, "digitalocean") - m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - spoof := []byte(`{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"attacker"}}`) - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", - bytes.NewReader(spoof)) - // Deliberately NO Authorization header — attacker sets the - // evidence flags in the body but has no credential. - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Errorf("evidence-spoof attempt: status = %d, want 401 (security gap)\n"+ - "client supplied authz_checked+authz_allowed in body without Bearer token; auth filter should reject before handler\n"+ - "body=%s", rec.Code, rec.Body.String()) - } - // Asset routes are equally protected. - assetReq := httptest.NewRequest(http.MethodGet, "/admin/infra-admin/resources.html", nil) - assetRec := httptest.NewRecorder() - m.router.ServeHTTP(assetRec, assetReq) - if assetRec.Code != http.StatusUnauthorized { - t.Errorf("unauthenticated asset request: status = %d, want 401; body=%s", assetRec.Code, assetRec.Body.String()) - } -} - -// TestInfraAdmin_AuthenticatedRequest_ReachesHandler is the -// positive counterpart — a valid Bearer token lets the request -// flow through the auth middleware to the handler, which then -// applies its own default-deny / authz check. Pins that auth is -// NOT a blanket-deny — properly authenticated requests still get -// to the typed protojson API surface. -func TestInfraAdmin_AuthenticatedRequest_ReachesHandler(t *testing.T) { - app, _, _, _ := newAuthEnabledApp(t, "digitalocean") - m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatal(err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatal(err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", - bytes.NewReader([]byte(`{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"user:alice"}}`))) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("authenticated request: status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } -} - -// configToMap round-trips a typed InfraAdminConfig through JSON -// into the map shape the factory expects (matching how the engine -// passes config maps to module factories at BuildFromConfig time). -func configToMap(t *testing.T, cfg InfraAdminConfig) map[string]any { - t.Helper() - data, err := json.Marshal(cfg) - if err != nil { - t.Fatalf("marshal cfg: %v", err) - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - t.Fatalf("unmarshal cfg map: %v", err) - } - return m -} - -// ── T4: auth refuse-empty + authz_module + subject propagation ──────────── - -// recordingLogger captures log messages from app.Logger() calls so -// tests can assert on specific warning strings. -type recordingLogger struct { - mu sync.Mutex - msgs []string -} - -func (l *recordingLogger) Debug(msg string, _ ...any) {} -func (l *recordingLogger) Info(msg string, _ ...any) {} -func (l *recordingLogger) Warn(msg string, args ...any) { - l.mu.Lock() - defer l.mu.Unlock() - l.msgs = append(l.msgs, msg) -} -func (l *recordingLogger) Error(msg string, _ ...any) {} -func (l *recordingLogger) lastWarn() string { - l.mu.Lock() - defer l.mu.Unlock() - if len(l.msgs) == 0 { - return "" - } - return l.msgs[len(l.msgs)-1] -} - -// recordingApp wraps infraMockApp and uses a recordingLogger. -type recordingApp struct { - *infraMockApp - logger *recordingLogger -} - -func newRecordingApp(base *infraMockApp) *recordingApp { - return &recordingApp{infraMockApp: base, logger: &recordingLogger{}} -} - -func (a *recordingApp) Logger() modular.Logger { return a.logger } - -// stubEnforcer is a minimal Enforcer for T4 tests. -type stubEnforcer struct { - allowed bool -} - -func (e *stubEnforcer) Enforce(_, _, _ string, _ ...string) (bool, error) { - return e.allowed, nil -} - -// TestInfraAdmin_Init_AuthModuleRequired asserts that Init returns an -// error when auth_module is empty and allow_unauthenticated is false. -func TestInfraAdmin_Init_AuthModuleRequired(t *testing.T) { - app, _, _ := newInfraAdminTestApp(t, "digitalocean") - cfg := standardCfg() - cfg.AllowUnauthenticated = false // explicit false - cfg.AuthModule = "" - - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - err := m.Init(app) - if err == nil { - t.Fatal("Init with no auth_module and allow_unauthenticated:false should return error") - } - const wantSubstr = "auth_module required" - if !strings.Contains(err.Error(), wantSubstr) { - t.Errorf("Init error %q should contain %q", err.Error(), wantSubstr) - } -} - -// TestInfraAdmin_Init_AllowUnauthenticatedNoError asserts that Init -// succeeds with allow_unauthenticated:true and no auth_module, and -// logs the exact warning string pinned by plan-review M-1. -func TestInfraAdmin_Init_AllowUnauthenticatedNoError(t *testing.T) { - base, _, _ := newInfraAdminTestApp(t, "digitalocean") - app := newRecordingApp(base) - cfg := standardCfg() // already has AllowUnauthenticated:true - cfg.AuthModule = "" - - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatalf("Init with allow_unauthenticated:true should not error: %v", err) - } - - const wantWarn = "infra.admin: mutation routes DISABLED (no auth_module); reads only" - if got := app.logger.lastWarn(); got != wantWarn { - t.Errorf("warning = %q, want %q", got, wantWarn) - } -} - -// TestInfraAdmin_Init_AuthzModuleResolved asserts that a configured -// authz_module is resolved as an Enforcer at Init. -func TestInfraAdmin_Init_AuthzModuleResolved(t *testing.T) { - base, _, _ := newInfraAdminTestApp(t, "digitalocean") - enforcer := &stubEnforcer{allowed: true} - if err := base.RegisterService("my-authz", enforcer); err != nil { - t.Fatalf("setup: %v", err) - } - - cfg := standardCfg() - cfg.AuthzModule = "my-authz" - - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(base); err != nil { - t.Fatalf("Init with authz_module should not error: %v", err) - } - if m.authz == nil { - t.Error("m.authz should be non-nil after Init with authz_module configured") - } -} - -// TestInfraAdmin_Init_AuthzModuleListedInDependencies asserts that a -// configured authz_module appears in both Dependencies() and -// RequiresServices() so the engine init-orders it before infra.admin. -func TestInfraAdmin_Init_AuthzModuleListedInDependencies(t *testing.T) { - cfg := standardCfg() - cfg.AuthzModule = "my-authz" - - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - - foundDep := false - for _, d := range m.Dependencies() { - if d == "my-authz" { - foundDep = true - } - } - if !foundDep { - t.Error("authz_module not in Dependencies()") - } - - foundSvc := false - for _, s := range m.RequiresServices() { - if s.Name == "my-authz" { - foundSvc = true - } - } - if !foundSvc { - t.Error("authz_module not in RequiresServices()") - } -} - -// TestInfraAdmin_SubjectFromRequest asserts that subjectFromRequest -// extracts the "sub" claim from the auth middleware's context value. -func TestInfraAdmin_SubjectFromRequest(t *testing.T) { - m := &InfraAdmin{name: "test"} - - // No claims in context → empty string. - req := httptest.NewRequest(http.MethodGet, "/", nil) - if got := m.subjectFromRequest(req); got != "" { - t.Errorf("no claims: want \"\", got %q", got) - } - - // Claims with "sub" → return sub. - claims := map[string]any{"sub": "user:alice", "email": "alice@example.com"} - ctx := context.WithValue(req.Context(), authClaimsContextKey, claims) - req2 := req.WithContext(ctx) - if got := m.subjectFromRequest(req2); got != "user:alice" { - t.Errorf("with claims: want \"user:alice\", got %q", got) - } - - // Claims without "sub" → empty string. - claims2 := map[string]any{"email": "bob@example.com"} - ctx2 := context.WithValue(req.Context(), authClaimsContextKey, claims2) - req3 := req.WithContext(ctx2) - if got := m.subjectFromRequest(req3); got != "" { - t.Errorf("claims without sub: want \"\", got %q", got) - } -} - -// ── T8: mutation route + requireBearer + audit 3-way tests ─────────────────── - -// startMutationModule is a helper that boots an InfraAdmin module with -// auth enabled (so mutation routes are registered). -func startMutationModule(t *testing.T) (*InfraAdmin, *authMwStub) { - t.Helper() - app, _, _, auth := newAuthEnabledApp(t, "digitalocean") - m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatalf("Start: %v", err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatalf("router.Start: %v", err) - } - return m, auth -} - -// TestInfraAdmin_MutationRoutesRegistered asserts that 4 mutation routes -// are registered when auth_module is configured. -func TestInfraAdmin_MutationRoutesRegistered(t *testing.T) { - m, _ := startMutationModule(t) - mutRoutes := []string{"/plan", "/apply", "/destroy", "/drift"} - for _, route := range mutRoutes { - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin"+route, - bytes.NewReader([]byte(`{}`))) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - // Should NOT be 404 (route must exist); anything else is acceptable - // from the handler (may be 200 with error in body). - if rec.Code == http.StatusNotFound { - t.Errorf("mutation route %s not registered (got 404)", route) - } - } -} - -// TestInfraAdmin_MutationRouteAbsentWithoutAuth asserts that mutation routes -// are NOT registered when allow_unauthenticated:true (no auth_module). -func TestInfraAdmin_MutationRouteAbsentWithoutAuth(t *testing.T) { - app, _, _ := newAppWithWorkflowSection(t, "digitalocean") - cfg := standardCfg() // AllowUnauthenticated:true, no AuthModule - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatalf("Start: %v", err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatalf("router.Start: %v", err) - } - for _, route := range []string{"/plan", "/apply", "/destroy", "/drift"} { - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin"+route, - bytes.NewReader([]byte(`{}`))) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("mutation route %s should be absent (no auth_module), got %d", route, rec.Code) - } - } -} - -// TestInfraAdmin_MutationRequiresBearerToken asserts that mutation routes -// return 401 when the Authorization: Bearer header is missing, even when -// the auth middleware lets the request through. -func TestInfraAdmin_MutationRequiresBearerToken(t *testing.T) { - m, _ := startMutationModule(t) - // No Authorization header at all. - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/plan", - bytes.NewReader([]byte(`{}`))) - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - // The auth stub lets it through, but requireBearer should reject it. - if rec.Code != http.StatusUnauthorized { - t.Errorf("mutation without Bearer: status = %d, want 401; body=%s", rec.Code, rec.Body.String()) - } -} - -// TestInfraAdmin_AuditResultFor3Way asserts the 3-way classification -// of auditResultFor. -func TestInfraAdmin_AuditResultFor3Way(t *testing.T) { - cases := []struct { - errMsg string - want string - }{ - {"", "ok"}, - {"authz evidence missing — admin middleware did not attach", "denied"}, - {"apply: infra:apply denied", "denied"}, - {"apply: plan is stale (desired_hash mismatch)", "denied"}, - {"apply: list state: connection refused", "error"}, - {"plan: no iac.provider registered", "error"}, - } - for _, tc := range cases { - got := auditResultFor(tc.errMsg) - if got != tc.want { - t.Errorf("auditResultFor(%q) = %q, want %q", tc.errMsg, got, tc.want) - } - } -} - -// ── T9: named security regression suite ────────────────────────────────────── - -// TestInfraAdmin_MutationRequiresBearer is the canonical CSRF regression: -// mutation routes MUST reject requests without Authorization: Bearer. -// (Renamed version of TestInfraAdmin_MutationRequiresBearerToken — same -// contract, keeps the T9 name the plan locked.) -func TestInfraAdmin_MutationRequiresBearer(t *testing.T) { - m, _ := startMutationModule(t) - for _, path := range []string{"/plan", "/apply", "/destroy", "/drift"} { - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin"+path, - bytes.NewReader([]byte(`{}`))) - // Explicitly no Authorization header. - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Errorf("%s without Bearer: want 401, got %d; body=%s", path, rec.Code, rec.Body.String()) - } - } -} - -// TestInfraAdmin_ApplyRejectsStalePlanHash is the TOCTOU regression: -// an apply request whose desired_hash does not match the in-process config -// MUST be rejected before any cloud operation runs. -func TestInfraAdmin_ApplyRejectsStalePlanHash(t *testing.T) { - m, _ := startMutationModule(t) - - body := `{"plan_id":"p1","desired_hash":"stale-deliberately-wrong","evidence":{"authz_checked":true,"authz_allowed":true}}` - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", - bytes.NewReader([]byte(body))) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - - // Stale hash is a provider/backend error → HTTP 500 (Bug 3 fix: writeMutationResponse - // maps non-authz output.Error → 500 so provider-error("denied" text) ≠ 403). - if rec.Code != http.StatusInternalServerError { - t.Fatalf("stale hash: want 500 (non-authz output.Error), got %d; body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "stale") { - t.Errorf("expected stale-hash error in response, got: %s", rec.Body.String()) - } -} - -// TestInfraAdmin_ConcurrentApplyReturns409 is the single-flight regression. -// It drives TWO goroutines concurrently against the same provider — one -// holds the mutex directly (simulating an in-flight apply) while the other -// hits the route and must see 409. A sequential variant would falsely pass -// (plan-review M-2). -func TestInfraAdmin_ConcurrentApplyReturns409(t *testing.T) { - m, _ := startMutationModule(t) - - // Manually lock the first provider's mutex to simulate an in-flight apply. - var held *sync.Mutex - for _, pm := range m.config.ProviderModules { - if mu, ok := m.providerMu[pm]; ok { - held = mu - break - } - } - if held == nil { - t.Skip("no provider mutex found (no ProviderModules configured)") - } - held.Lock() - defer held.Unlock() - - // Now an apply request MUST see 409 (mutex already locked). - body := `{"plan_id":"p1","desired_hash":"any","evidence":{"authz_checked":true,"authz_allowed":true}}` - req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", - bytes.NewReader([]byte(body))) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - - // Run the request in a goroutine to properly simulate concurrency. - done := make(chan struct{}) - go func() { - defer close(done) - m.router.ServeHTTP(rec, req) - }() - <-done - - if rec.Code != http.StatusConflict { - t.Errorf("concurrent apply: want 409, got %d; body=%s", rec.Code, rec.Body.String()) - } -} - -// TestInfraAdmin_ViewerCannotApply is the write-tier RBAC regression: -// a subject that the authz module grants only infra:read MUST receive an -// error on apply/destroy routes, server-side, regardless of what the -// client body asserts in evidence.granted_permissions. -func TestInfraAdmin_ViewerCannotApply(t *testing.T) { - app, _, _, _ := newAuthEnabledApp(t, "digitalocean") - enforcer := &stubEnforcer{allowed: false} // denies everything - if err := app.RegisterService("my-authz", enforcer); err != nil { // F3 fix - t.Fatalf("setup: %v", err) - } - - cfg := standardAuthCfg() - cfg.AuthzModule = "my-authz" - - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatalf("Start: %v", err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatalf("router.Start: %v", err) - } - - viewerCtx := func(r *http.Request) *http.Request { - ctx := context.WithValue(r.Context(), authClaimsContextKey, map[string]any{"sub": "viewer"}) - return r.WithContext(ctx) - } - - // Apply: client claims allowed, server Enforcer denies → HTTP 403 (Bug 3 fix). - applyReq := viewerCtx(httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", - bytes.NewReader([]byte(`{"evidence":{"authz_checked":true,"authz_allowed":true},"desired_hash":"any"}`)))) - applyReq.Header.Set("Authorization", "Bearer test-token") - applyRec := httptest.NewRecorder() - m.router.ServeHTTP(applyRec, applyReq) - if applyRec.Code != http.StatusForbidden { - t.Fatalf("apply: want 403 (typed ErrAuthzDenied), got %d; body=%s", applyRec.Code, applyRec.Body.String()) - } - if !strings.Contains(applyRec.Body.String(), "denied") { - t.Errorf("viewer apply should be denied by server-side Enforcer; body=%s", applyRec.Body.String()) - } - - // Destroy: same enforcer denies infra:destroy too (F1 fix — cover destroy route). - destroyReq := viewerCtx(httptest.NewRequest(http.MethodPost, "/api/infra-admin/destroy", - bytes.NewReader([]byte(`{"refs":[{"name":"vpc1","type":"infra.vpc"}],"confirm_hash":"any","evidence":{"authz_checked":true,"authz_allowed":true}}`)))) - destroyReq.Header.Set("Authorization", "Bearer test-token") - destroyRec := httptest.NewRecorder() - m.router.ServeHTTP(destroyRec, destroyReq) - if destroyRec.Code != http.StatusForbidden { - t.Fatalf("destroy: want 403 (typed ErrAuthzDenied), got %d; body=%s", destroyRec.Code, destroyRec.Body.String()) - } - if !strings.Contains(destroyRec.Body.String(), "denied") { - t.Errorf("viewer destroy should be denied by server-side Enforcer; body=%s", destroyRec.Body.String()) - } -} - -// deniedPlanProvider wraps infraMockProvider and overrides Plan to return -// an error whose message contains "denied" — used by the provider-error→500 -// discriminator test to verify that strings.Contains("denied") is NOT used -// for HTTP status classification (typed ErrAuthzDenied sentinel instead). -type deniedPlanProvider struct { - infraMockProvider -} - -func (p *deniedPlanProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { - return nil, errors.New("provider: access denied to cloud API") -} - -// TestInfraAdmin_TypedAuthzDenied_Returns403 pins the 4 typed HTTP-status -// discriminator behaviors introduced by Bug 3 + Bug 4: -// -// viewer→/plan=403 (handlePlanResource Enforcer gate, Bug 4) -// viewer→/apply=403 (handler.ErrAuthzDenied → writeMutationResponse, Bug 3) -// operator→/apply=200 (happy path unaffected) -// provider-error("denied" text)→500 NOT 403 (strings.Contains FP eliminated) -func TestInfraAdmin_TypedAuthzDenied_Returns403(t *testing.T) { - // subjectCtx injects a JWT "sub" claim into the request context - // so subjectFromRequest() extracts the right principal. - subjectCtx := func(r *http.Request, sub string) *http.Request { - ctx := context.WithValue(r.Context(), authClaimsContextKey, map[string]any{"sub": sub}) - return r.WithContext(ctx) - } - - // startWithConfig boots an InfraAdmin with auth+authz+provider registered. - startWithConfig := func(t *testing.T, enforcer Enforcer, prov interfaces.IaCProvider) *InfraAdmin { - t.Helper() - app, _, _, _ := newAuthEnabledApp(t, "digitalocean") - if enforcer != nil { - if err := app.RegisterService("my-authz", enforcer); err != nil { - t.Fatalf("setup: RegisterService(my-authz): %v", err) - } - } - if prov != nil { - // Override the default do-provider so the module uses our custom one. - if err := app.RegisterService("do-provider", prov); err != nil { - t.Fatalf("setup: RegisterService(do-provider): %v", err) - } - } - cfg := standardAuthCfg() - if enforcer != nil { - cfg.AuthzModule = "my-authz" - } - m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) - if err := m.Init(app); err != nil { - t.Fatalf("Init: %v", err) - } - if err := m.Start(context.Background()); err != nil { - t.Fatalf("Start: %v", err) - } - if err := m.router.Start(context.Background()); err != nil { - t.Fatalf("router.Start: %v", err) - } - return m - } - - // ── viewer→/plan=403 ───────────────────────────────────────────────────── - // handlePlanResource now calls m.authz.Enforce before handler.PlanResource - // (Bug 4 fix). Viewer subject denied → HTTP 403 directly from module layer. - // Additional assertions (Copilot findings): - // - Body is proto-JSON AdminPlanOutput (not plaintext) — consistent with apply/destroy 403s. - // - Body does NOT contain the subject string "viewer" — no principal leak. - t.Run("viewer/plan=403", func(t *testing.T) { - enforcer := &stubEnforcer{allowed: false} - m := startWithConfig(t, enforcer, nil) - - req := subjectCtx(httptest.NewRequest(http.MethodPost, "/api/infra-admin/plan", - bytes.NewReader([]byte(`{"evidence":{"authz_checked":true,"authz_allowed":true}}`))), "viewer") - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusForbidden { - t.Errorf("viewer/plan: want 403, got %d; body=%s", rec.Code, rec.Body.String()) - } - // Body must be proto-JSON AdminPlanOutput, not plaintext (Copilot finding 1+2). - if ct := rec.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("viewer/plan 403: Content-Type = %q, want application/json (proto-JSON body)", ct) - } - var planOut adminpb.AdminPlanOutput - if err := protojson.Unmarshal(rec.Body.Bytes(), &planOut); err != nil { - t.Errorf("viewer/plan 403: body is not valid AdminPlanOutput JSON: %v\n%s", err, rec.Body.String()) - } - // Subject must NOT appear in response body — no principal leakage. - if strings.Contains(rec.Body.String(), "viewer") { - t.Errorf("viewer/plan 403: body contains subject 'viewer' — principal leak; body=%s", rec.Body.String()) - } - }) - - // ── viewer→/apply=403 ──────────────────────────────────────────────────── - // handler.ApplyResource Gate 2 returns ErrAuthzDenied → writeMutationResponse → 403. - t.Run("viewer/apply=403", func(t *testing.T) { - enforcer := &stubEnforcer{allowed: false} - m := startWithConfig(t, enforcer, nil) - - // Compute correct desired_hash for empty desiredSpecs + empty state. - hash := handler.DesiredHash(nil, nil, nil) - body := `{"desired_hash":"` + hash + `","evidence":{"authz_checked":true,"authz_allowed":true}}` - req := subjectCtx(httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", - bytes.NewReader([]byte(body))), "viewer") - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusForbidden { - t.Errorf("viewer/apply: want 403, got %d; body=%s", rec.Code, rec.Body.String()) - } - }) - - // ── operator→/apply=200 ────────────────────────────────────────────────── - // operator is allowed → plan succeeds (empty desired specs) → apply succeeds → 200. - t.Run("operator/apply=200", func(t *testing.T) { - enforcer := &stubEnforcer{allowed: true} - m := startWithConfig(t, enforcer, nil) - - hash := handler.DesiredHash(nil, nil, nil) - body := `{"desired_hash":"` + hash + `","evidence":{"authz_checked":true,"authz_allowed":true}}` - req := subjectCtx(httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", - bytes.NewReader([]byte(body))), "operator") - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("operator/apply: want 200, got %d; body=%s", rec.Code, rec.Body.String()) - } - }) - - // ── provider-error("denied" text)→500 NOT 403 ─────────────────────────── - // The provider's Plan returns an error whose message contains "denied". - // writeMutationResponse must use errors.Is(err, ErrAuthzDenied) — NOT - // strings.Contains — so this maps to 500 (backend error), NOT 403 (authz). - t.Run("provider-error-denied-text/apply=500-not-403", func(t *testing.T) { - enforcer := &stubEnforcer{allowed: true} - prov := &deniedPlanProvider{infraMockProvider: infraMockProvider{name: "digitalocean"}} - m := startWithConfig(t, enforcer, prov) - - hash := handler.DesiredHash(nil, nil, nil) - body := `{"desired_hash":"` + hash + `","evidence":{"authz_checked":true,"authz_allowed":true}}` - req := subjectCtx(httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", - bytes.NewReader([]byte(body))), "operator") - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - m.router.ServeHTTP(rec, req) - if rec.Code == http.StatusForbidden { - t.Errorf("provider-error with 'denied' text: got 403 (strings.Contains FP!) want 500; body=%s", rec.Body.String()) - } - if rec.Code != http.StatusInternalServerError { - t.Errorf("provider-error with 'denied' text: want 500, got %d; body=%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "denied") { - t.Errorf("provider-error body should contain 'denied' (provider error text); got: %s", rec.Body.String()) - } - }) -} - -// TestInfraAdmin_AuditDistinguishesDeniedFromError verifies that the -// 3-way audit classification correctly distinguishes authz denials from -// backend errors (extended from T8's TestInfraAdmin_AuditResultFor3Way). -func TestInfraAdmin_AuditDistinguishesDeniedFromError(t *testing.T) { - // Denial (authz/evidence/stale markers) → "denied" - for _, msg := range []string{ - "authz evidence missing", - "infra:apply denied", - "plan is stale (desired_hash mismatch)", - } { - if got := auditResultFor(msg); got != "denied" { - t.Errorf("auditResultFor(%q) = %q, want 'denied'", msg, got) - } - } - // Error (provider failure) → "error" - for _, msg := range []string{ - "apply: list state: connection refused", - "plan: no iac.provider registered", - "destroy: provider timeout", - } { - if got := auditResultFor(msg); got != "error" { - t.Errorf("auditResultFor(%q) = %q, want 'error'", msg, got) - } - } -} - -// TestInfraAdmin_AuditResultFromErr pins auditResultFromErr — the typed -// mutation-route classifier that replaces strings.Contains in the audit -// path. The key regression: a provider error whose message contains -// "denied" must log as "error", NOT "denied". -func TestInfraAdmin_AuditResultFromErr(t *testing.T) { - cases := []struct { - name string - err error - outErr string - want string - }{ - {"success", nil, "", "ok"}, - {"authz sentinel", handler.ErrAuthzDenied, "apply: infra:apply denied", "denied"}, - // The critical false-positive regression: provider error containing - // "denied" must NOT be classified as "denied" (strings.Contains would - // have done so). Only errors.Is(ErrAuthzDenied) triggers "denied". - {"provider error with denied text", nil, "apply: plan: provider: access denied to cloud API", "error"}, - {"stale hash", nil, "apply: plan is stale (desired_hash mismatch)", "error"}, - {"no provider registered", nil, "plan: no iac.provider registered", "error"}, - {"evidence denial via sentinel", handler.ErrAuthzDenied, "authz evidence missing", "denied"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := auditResultFromErr(tc.err, tc.outErr) - if got != tc.want { - t.Errorf("auditResultFromErr(%v, %q) = %q, want %q", tc.err, tc.outErr, got, tc.want) - } - }) - } -} diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index 1adb08bf..d44ae781 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -2205,6 +2205,7 @@ func (r *StepSchemaRegistry) registerBuiltins() { Outputs: []StepOutputDef{ {Key: "provider", Type: "string", Description: "Provider service name"}, {Key: "supported", Type: "boolean", Description: "Whether drift detection is supported"}, + {Key: "reason", Type: "string", Description: "Why drift detection is unsupported (set only when supported=false)"}, {Key: "any_drifted", Type: "boolean", Description: "Whether any resource has drifted"}, {Key: "drifts", Type: "[]any", Description: "Per-resource drift results"}, {Key: "count", Type: "number", Description: "Number of resources checked"}, diff --git a/scripts/check-vendored-proto.sh b/scripts/check-vendored-proto.sh deleted file mode 100755 index a2f4c91e..00000000 --- a/scripts/check-vendored-proto.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bash -# check-vendored-proto.sh — assert iac/admin/testdata/infra.proto is in sync -# with the upstream GoCodeAlone/workflow-plugin-infra proto descriptor. -# -# Exit 0: vendored copy matches upstream message set. -# Exit 1: drift detected or environment error. -# -# How it works: -# 1. Reads the "Source version: " comment from the vendored file header -# to know which upstream tag to fetch. -# 2. Fetches the upstream proto from GitHub at that tag via the raw API -# (no local checkout required — CI-safe). -# 3. Extracts the set of `message *Config { ... }` names from both files. -# 4. Diffs the two sets. Any addition or removal fails the check. -# -# Refresh procedure: `make vendor-infra-proto` (see Makefile target). -# The vendored file header must then be updated: update "Source version:" to -# the new upstream tag. -# -# Usage: bash scripts/check-vendored-proto.sh [--vendored PATH] [--tag TAG] -# --vendored PATH Override the vendored proto path -# (default: iac/admin/testdata/infra.proto) -# --tag TAG Override the upstream tag to fetch -# (default: read from vendored file header) -set -euo pipefail - -VENDORED_PROTO="${VENDORED_PROTO:-iac/admin/testdata/infra.proto}" -UPSTREAM_REPO="GoCodeAlone/workflow-plugin-infra" -UPSTREAM_PATH="internal/contracts/infra.proto" - -# Parse flags. -while [[ $# -gt 0 ]]; do - case "$1" in - --vendored) VENDORED_PROTO="$2"; shift 2 ;; - --tag) OVERRIDE_TAG="$2"; shift 2 ;; - *) echo "Unknown flag: $1" >&2; exit 1 ;; - esac -done - -# Resolve script root (works whether called from repo root or scripts/). -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -VENDORED_PROTO="$REPO_ROOT/$VENDORED_PROTO" - -if [[ ! -f "$VENDORED_PROTO" ]]; then - echo "ERROR: vendored proto not found: $VENDORED_PROTO" >&2 - exit 1 -fi - -# Extract the source tag from the vendored file header. -# Expected line: "// Source version: v1.0.0 (sourced 2026-05-27)" -if [[ -n "${OVERRIDE_TAG:-}" ]]; then - UPSTREAM_TAG="$OVERRIDE_TAG" -else - UPSTREAM_TAG=$(grep -m1 '// Source version:' "$VENDORED_PROTO" | \ - sed 's|// Source version: \([^ ]*\).*|\1|') - if [[ -z "$UPSTREAM_TAG" ]]; then - echo "ERROR: cannot read '// Source version: ' from $VENDORED_PROTO" >&2 - echo " Update the header or pass --tag " >&2 - exit 1 - fi -fi - -echo "Checking vendored proto against upstream $UPSTREAM_REPO @ $UPSTREAM_TAG ..." - -# Fetch upstream proto. -UPSTREAM_URL="https://raw.githubusercontent.com/$UPSTREAM_REPO/$UPSTREAM_TAG/$UPSTREAM_PATH" -UPSTREAM_TMP=$(mktemp /tmp/infra-proto-upstream.XXXXXX.proto) -trap 'rm -f "$UPSTREAM_TMP"' EXIT - -if ! curl -fsSL "$UPSTREAM_URL" -o "$UPSTREAM_TMP"; then - echo "ERROR: failed to fetch $UPSTREAM_URL" >&2 - echo " Check connectivity and that tag '$UPSTREAM_TAG' exists in $UPSTREAM_REPO" >&2 - exit 1 -fi - -# Extract *Config message names from a proto file (line-by-line regex). -# Scope is intentionally limited to `*Config` messages — these are the -# typed resource configs that catalog_proto_parity_test.go asserts have -# catalog entries. Non-Config messages (service RPCs, generic types) are -# out of scope for the parity test and are therefore excluded here too. -extract_config_messages() { - grep -oE '^[[:space:]]*message[[:space:]]+([A-Za-z0-9_]+Config)[[:space:]]*\{' "$1" \ - | sed -E 's|.*message[[:space:]]+([A-Za-z0-9_]+Config)[[:space:]]*\{.*|\1|' \ - | sort -} - -VENDORED_MSGS=$(extract_config_messages "$VENDORED_PROTO") -UPSTREAM_MSGS=$(extract_config_messages "$UPSTREAM_TMP") - -if [[ "$VENDORED_MSGS" == "$UPSTREAM_MSGS" ]]; then - echo "OK: vendored infra.proto message set is in sync with upstream $UPSTREAM_TAG." - exit 0 -fi - -# Compute diff for the error message. -DIFF=$(diff <(echo "$VENDORED_MSGS") <(echo "$UPSTREAM_MSGS") || true) -ADDED=$(echo "$DIFF" | grep '^>' | sed 's/^> / + /' || true) -REMOVED=$(echo "$DIFF" | grep '^<' | sed 's/^< / - /' || true) - -echo "ERROR: vendored infra.proto is stale!" >&2 -echo "" >&2 -if [[ -n "$REMOVED" ]]; then - echo "Messages in vendored but NOT in upstream (upstream removed them):" >&2 - echo "$REMOVED" >&2 -fi -if [[ -n "$ADDED" ]]; then - echo "Messages in upstream but NOT in vendored (upstream added them):" >&2 - echo "$ADDED" >&2 -fi -echo "" >&2 -echo "To refresh, run:" >&2 -echo " make vendor-infra-proto" >&2 -echo "" >&2 -echo "Then update '// Source version:' in $VENDORED_PROTO to match the new upstream tag." >&2 -exit 1 From 85dc68298cd7ab36f39ca5c725be703624b38fe4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 04:43:47 -0400 Subject: [PATCH 2/2] =?UTF-8?q?test:=20PR-3=20boot=20smoke=20=E2=80=94=20e?= =?UTF-8?q?ngine=20boots=20without=20infra.admin;=20type:infra.admin=20rej?= =?UTF-8?q?ected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot smoke transcript (binaries built from this commit): POSITIVE (engine starts with valid config): $ wfctl validate example/api-server-config.yaml PASS /…/example/api-server-config.yaml (5 modules, 1 workflows, 0 triggers) exit code: 0 NEGATIVE (infra.admin type is gone — config rejected at load): Config: modules: - name: bad-admin type: infra.admin $ server -config /tmp/infra-admin-negative-test.yaml 2026/06/02 04:43:04 Setup error: failed to build engine: failed to build workflow: config validation failed: config validation failed with 1 error(s): - modules[0].type: unknown module type "infra.admin" exit code: 1 Full suite: GOWORK=off go test ./... → all packages ok, 0 FAIL Lint: golangci-lint run --new-from-rev=origin/main → 0 issues Co-Authored-By: Claude Sonnet 4.6