diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 590ffe67..d4794776 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/sha256" "encoding/json" "errors" "fmt" @@ -1375,25 +1374,13 @@ func isAbstractSize(s interfaces.Size) bool { // desired-state inputs: specs sorted by name and JSON-serialised. It is // embedded in plan.json by runInfraPlan and verified by runInfraApply // --plan to detect plans that are stale relative to the current config. +// +// Deprecated: this shim delegates to wfctlhelpers.DesiredStateHash, which is +// the canonical implementation and should be called directly by new code. +// Call sites here pass pre-resolved specs (resolveSpecsAgainstState has +// already been applied), so nil cfg + nil current produce identical results. func desiredStateHash(specs []interfaces.ResourceSpec) string { - // Do NOT short-circuit for empty specs: a plan that removes all resources - // ("delete all") has a valid, deterministic hash (sha256("[]")). Returning "" - // for empty specs would block such plans with a misleading "no hash" error. - // The "" sentinel is reserved exclusively for marshal failures below. - sorted := make([]interfaces.ResourceSpec, len(specs)) - copy(sorted, specs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - data, err := json.Marshal(sorted) - if err != nil { - // Should never happen for YAML-decoded structs, but return the empty - // sentinel rather than silently hashing nil bytes — callers treat "" - // as "hash unavailable" and will reject the plan with a clear error. - return "" - } - sum := sha256.Sum256(data) - return fmt.Sprintf("%x", sum) + return wfctlhelpers.DesiredStateHash(nil, specs, nil, "") } // loadPlanFromFile reads and deserialises a plan.json written by wfctl infra plan -o. diff --git a/iac/admin/handler/apply_resource.go b/iac/admin/handler/apply_resource.go new file mode 100644 index 00000000..ff236887 --- /dev/null +++ b/iac/admin/handler/apply_resource.go @@ -0,0 +1,276 @@ +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}, nil + } + + // 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 { + return &adminpb.AdminApplyOutput{Error: "apply: infra:apply denied for subject " + subject}, nil + } + } + + 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 new file mode 100644 index 00000000..c1554921 --- /dev/null +++ b/iac/admin/handler/apply_resource_test.go @@ -0,0 +1,236 @@ +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/iac/stubprovider" + "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 := stubprovider.New() + 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 err != nil { + t.Fatalf("ApplyResource: unexpected Go error: %v", err) + } + 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 := stubprovider.New() + 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 err != nil { + t.Fatalf("ApplyResource: unexpected Go error: %v", err) + } + 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 := stubprovider.New() + 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 := stubprovider.New() + 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/destroy_resource.go b/iac/admin/handler/destroy_resource.go new file mode 100644 index 00000000..88693016 --- /dev/null +++ b/iac/admin/handler/destroy_resource.go @@ -0,0 +1,124 @@ +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}, nil + } + + // 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 { + return &adminpb.AdminDestroyOutput{Error: "destroy: infra:destroy denied for subject " + subject}, nil + } + } + + // 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 new file mode 100644 index 00000000..883c7ae5 --- /dev/null +++ b/iac/admin/handler/destroy_resource_test.go @@ -0,0 +1,119 @@ +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/iac/stubprovider" + "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 := stubprovider.New() + 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 err != nil { + t.Fatalf("DestroyResource: unexpected Go error: %v", err) + } + 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 := stubprovider.New() + 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 err != nil { + t.Fatalf("DestroyResource: unexpected Go error: %v", err) + } + 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 := stubprovider.New() + 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 := stubprovider.New() + 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 new file mode 100644 index 00000000..aca30cb4 --- /dev/null +++ b/iac/admin/handler/drift_check.go @@ -0,0 +1,64 @@ +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 new file mode 100644 index 00000000..b0def60e --- /dev/null +++ b/iac/admin/handler/drift_check_test.go @@ -0,0 +1,81 @@ +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/iac/stubprovider" + "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 := stubprovider.New() + 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 := stubprovider.New() + 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/plan_resource.go b/iac/admin/handler/plan_resource.go new file mode 100644 index 00000000..06e3ba2c --- /dev/null +++ b/iac/admin/handler/plan_resource.go @@ -0,0 +1,195 @@ +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 +// short-circuits with Output.error (HTTP stays 200; consumer sniffs tag-100). +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}, nil + } + 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 new file mode 100644 index 00000000..3f94d017 --- /dev/null +++ b/iac/admin/handler/plan_resource_test.go @@ -0,0 +1,124 @@ +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/iac/stubprovider" + "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 := stubprovider.New() + 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 err != nil { + t.Fatalf("PlanResource: unexpected error: %v", err) + } + 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 := stubprovider.New() + 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 := stubprovider.New() + 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 index 7b663bf5..7985f2ec 100644 --- a/iac/admin/proto/infra_admin.pb.go +++ b/iac/admin/proto/infra_admin.pb.go @@ -1387,6 +1387,802 @@ func (x *AdminAuditEntry) GetAppContext() string { 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 = "" + @@ -1499,13 +2295,72 @@ const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + "\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" + - "appContext2\x9a\x04\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.AdminGenerateConfigOutputB9Z7github.com/GoCodeAlone/workflow/iac/admin/proto;adminpbb\x06proto3" + "\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 @@ -1519,7 +2374,7 @@ func file_iac_admin_proto_infra_admin_proto_rawDescGZIP() []byte { return file_iac_admin_proto_infra_admin_proto_rawDescData } -var file_iac_admin_proto_infra_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +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 @@ -1538,7 +2393,19 @@ var file_iac_admin_proto_infra_admin_proto_goTypes = []any{ (*AdminGenerateConfigInput)(nil), // 14: workflow.iac.v1.AdminGenerateConfigInput (*AdminGenerateConfigOutput)(nil), // 15: workflow.iac.v1.AdminGenerateConfigOutput (*AdminAuditEntry)(nil), // 16: workflow.iac.v1.AdminAuditEntry - nil, // 17: workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntry + (*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 @@ -1551,23 +2418,42 @@ var file_iac_admin_proto_infra_admin_proto_depIdxs = []int32{ 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 - 17, // 10: workflow.iac.v1.AdminGenerateConfigInput.field_values:type_name -> workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntry + 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 - 3, // 12: workflow.iac.v1.InfraAdminService.ListResources:input_type -> workflow.iac.v1.AdminListResourcesInput - 5, // 13: workflow.iac.v1.InfraAdminService.GetResource:input_type -> workflow.iac.v1.AdminGetResourceInput - 9, // 14: workflow.iac.v1.InfraAdminService.ListResourceTypes:input_type -> workflow.iac.v1.AdminListResourceTypesInput - 12, // 15: workflow.iac.v1.InfraAdminService.ListProviders:input_type -> workflow.iac.v1.AdminListProvidersInput - 14, // 16: workflow.iac.v1.InfraAdminService.GenerateConfig:input_type -> workflow.iac.v1.AdminGenerateConfigInput - 4, // 17: workflow.iac.v1.InfraAdminService.ListResources:output_type -> workflow.iac.v1.AdminListResourcesOutput - 6, // 18: workflow.iac.v1.InfraAdminService.GetResource:output_type -> workflow.iac.v1.AdminGetResourceOutput - 10, // 19: workflow.iac.v1.InfraAdminService.ListResourceTypes:output_type -> workflow.iac.v1.AdminListResourceTypesOutput - 13, // 20: workflow.iac.v1.InfraAdminService.ListProviders:output_type -> workflow.iac.v1.AdminListProvidersOutput - 15, // 21: workflow.iac.v1.InfraAdminService.GenerateConfig:output_type -> workflow.iac.v1.AdminGenerateConfigOutput - 17, // [17:22] is the sub-list for method output_type - 12, // [12:17] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 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() } @@ -1581,7 +2467,7 @@ func file_iac_admin_proto_infra_admin_proto_init() { 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: 18, + NumMessages: 30, NumExtensions: 0, NumServices: 1, }, diff --git a/iac/admin/proto/infra_admin.proto b/iac/admin/proto/infra_admin.proto index 21669cbc..3a1b33d3 100644 --- a/iac/admin/proto/infra_admin.proto +++ b/iac/admin/proto/infra_admin.proto @@ -269,16 +269,162 @@ message AdminAuditEntry { string app_context = 7; } -// InfraAdminService is the typed read-only surface. v1 has 5 RPCs; -// the HTTP audit-tail endpoint (GET /api/infra-admin/audit) streams -// AdminAuditEntry ndjson outside of this gRPC service per design doc -// §Access logging. Mutating endpoints (PLAN/APPLY/DESTROY/ -// DRIFT-CHECK) are out of scope for v1 — they remain in -// `wfctl infra apply/destroy/drift`. +// 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 index 36ad4fca..00fe0255 100644 --- a/iac/admin/proto/proto_roundtrip_test.go +++ b/iac/admin/proto/proto_roundtrip_test.go @@ -157,3 +157,410 @@ func TestAdminListResourcesOutput_ErrorField(t *testing.T) { 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/ui_dist/actions.html b/iac/admin/ui_dist/actions.html new file mode 100644 index 00000000..bf510ac5 --- /dev/null +++ b/iac/admin/ui_dist/actions.html @@ -0,0 +1,76 @@ + + + + + + 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 new file mode 100644 index 00000000..12df4c13 --- /dev/null +++ b/iac/admin/ui_dist/actions.js @@ -0,0 +1,182 @@ +// 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/resource.html b/iac/admin/ui_dist/resource.html index 42a1ac2b..4a478cef 100644 --- a/iac/admin/ui_dist/resource.html +++ b/iac/admin/ui_dist/resource.html @@ -26,6 +26,66 @@

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 index 376faa30..c41bc886 100644 --- a/iac/admin/ui_dist/resource.js +++ b/iac/admin/ui_dist/resource.js @@ -1,14 +1,42 @@ // resource.js — drives /admin/infra-admin/resource.html?name=. // CSP-compliant: external file only. // -// Endpoint: +// 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. +// 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 => ({ @@ -20,6 +48,16 @@ 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; @@ -37,6 +75,18 @@ function decodeProtoBytes(b64) { } } +// 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', @@ -49,6 +99,27 @@ async function postJSON(path, body) { 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 = ''; @@ -92,6 +163,67 @@ function renderRedactionNote(redacted) { 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'); @@ -99,6 +231,8 @@ async function load() { 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. @@ -111,13 +245,165 @@ async function load() { body, ); const r = data.resource || {}; - renderSummary(r.summary); + 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_test.go b/iac/admin/ui_test.go index 503cc1e2..2a3f73f4 100644 --- a/iac/admin/ui_test.go +++ b/iac/admin/ui_test.go @@ -1,6 +1,7 @@ package admin_test import ( + "io" "io/fs" "strings" "testing" @@ -9,12 +10,12 @@ import ( ) // TestAssetFS_AllExpectedFilesEmbedded pins the file list the host -// module (T15) 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. +// 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. +// Per plan §Task 13 (T12 additions: actions.html, actions.js). func TestAssetFS_AllExpectedFilesEmbedded(t *testing.T) { expected := []string{ "ui_dist/resources.html", @@ -24,6 +25,9 @@ func TestAssetFS_AllExpectedFilesEmbedded(t *testing.T) { "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) { @@ -78,3 +82,152 @@ func TestAssetFS_ListsAllAndOnlyExpected(t *testing.T) { 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/stubprovider/provider.go b/iac/stubprovider/provider.go new file mode 100644 index 00000000..f108cbe5 --- /dev/null +++ b/iac/stubprovider/provider.go @@ -0,0 +1,208 @@ +// Package stubprovider supplies a minimal in-process interfaces.IaCProvider +// for use in integration tests and the scenario-92 demo stack. +// +// The Provider does NOT make real cloud API calls. Every lifecycle method +// returns a deterministic, non-error result: +// +// - Plan: compares desired vs current by name; emits "create" for +// resources absent from current and "delete" for resources +// absent from desired. +// - ResourceDriver: returns a stub driver whose Create/Update/Delete all +// succeed, enabling wfctlhelpers.ApplyPlanWithHooks to run +// end-to-end without any external plugin subprocess. +// - Destroy: returns all supplied refs as Destroyed names (no-op). +// - DetectDrift: returns Drifted:false for every ref. +// +// This package imports only interfaces — no new import cycles. +package stubprovider + +import ( + "context" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// Provider is the exported stub IaCProvider. Use New() to obtain an instance. +type Provider struct{} + +// Compile-time conformance check. +var _ interfaces.IaCProvider = (*Provider)(nil) + +// New returns an initialized stub Provider. +func New() *Provider { return &Provider{} } + +// Name returns the stub provider identifier. +func (p *Provider) Name() string { return "stub" } + +// Version returns the stub provider version. +func (p *Provider) Version() string { return "0.0.0-stub" } + +// Initialize is a no-op for the stub. +func (p *Provider) Initialize(_ context.Context, _ map[string]any) error { return nil } + +// Capabilities returns nil — the stub does not declare optional capabilities. +func (p *Provider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } + +// Plan compares desired specs against current state by name and returns +// a plan with "create" for each new resource and "delete" for each +// resource present in current but absent from desired. Resources present +// in both are returned as "update" actions (no-op at apply time; the +// stub driver's Update returns success). +func (p *Provider) 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 +} + +// Destroy returns all supplied refs as Destroyed names. +func (p *Provider) Destroy(_ context.Context, refs []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + destroyed := make([]string, 0, len(refs)) + for _, r := range refs { + destroyed = append(destroyed, r.Name) + } + return &interfaces.DestroyResult{Destroyed: destroyed}, nil +} + +// Status returns nil — the stub does not probe live cloud status. +func (p *Provider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, nil +} + +// DetectDrift returns Drifted:false with DriftClassInSync for every ref. +func (p *Provider) 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 +} + +// Import returns nil — the stub does not support resource import. +func (p *Provider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} + +// ResolveSizing returns nil — the stub does not resolve sizing. +func (p *Provider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} + +// ResourceDriver returns a stub driver for any resource type. +func (p *Provider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &stubDriver{}, nil +} + +// SupportedCanonicalKeys returns nil. +func (p *Provider) SupportedCanonicalKeys() []string { return nil } + +// BootstrapStateBackend returns nil — the stub does not manage state backends. +func (p *Provider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} + +// Close is a no-op. +func (p *Provider) Close() error { return nil } + +// stubDriver is an in-process ResourceDriver whose lifecycle methods all +// return success with a minimal ResourceOutput. +type stubDriver struct{} + +// Compile-time conformance check. +var _ interfaces.ResourceDriver = (*stubDriver)(nil) + +// Create returns a ResourceOutput with the spec's name and type. +func (d *stubDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{ + Name: spec.Name, + Type: spec.Type, + ProviderID: "stub-" + spec.Name, + }, nil +} + +// Read returns a ResourceOutput with the ref's name and type. +func (d *stubDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + }, nil +} + +// Update returns a ResourceOutput with the spec's name and type. +func (d *stubDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + pid := ref.ProviderID + if pid == "" { + pid = "stub-" + spec.Name + } + return &interfaces.ResourceOutput{ + Name: spec.Name, + Type: spec.Type, + ProviderID: pid, + }, nil +} + +// Delete is a no-op. +func (d *stubDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil } + +// Diff returns a DiffResult indicating no changes needed. +func (d *stubDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + return &interfaces.DiffResult{ + NeedsUpdate: false, + NeedsReplace: false, + Changes: nil, + }, nil +} + +// HealthCheck returns nil — the stub does not probe health. +func (d *stubDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { + return nil, nil +} + +// Scale returns nil — the stub does not support scaling. +func (d *stubDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { + return nil, nil +} + +// SensitiveKeys returns nil. +func (d *stubDriver) SensitiveKeys() []string { return nil } diff --git a/iac/stubprovider/provider_test.go b/iac/stubprovider/provider_test.go new file mode 100644 index 00000000..688deb51 --- /dev/null +++ b/iac/stubprovider/provider_test.go @@ -0,0 +1,167 @@ +// Package stubprovider_test exercises the stub IaCProvider used by +// scenario 92 and integration tests. The stub must be loadable without +// any external plugin subprocess — it runs entirely in-process. +package stubprovider_test + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/iac/stubprovider" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestStub_InterfaceConformance asserts that New() returns a non-nil +// provider. The compile-time guard (var _ in provider.go) already verifies +// type satisfaction; this test catches a nil-return regression at runtime. +func TestStub_InterfaceConformance(t *testing.T) { + p := stubprovider.New() + if p == nil { + t.Fatal("New() returned nil") + } +} + +// TestStub_Plan_CreateAction asserts that Plan on a 1-spec desired set +// with no current state returns a plan with 1 "create" action. +func TestStub_Plan_CreateAction(t *testing.T) { + p := stubprovider.New() + desired := []interfaces.ResourceSpec{ + {Name: "my-vpc", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, + } + plan, err := p.Plan(context.Background(), desired, nil) + if err != nil { + t.Fatalf("Plan: unexpected error: %v", err) + } + if plan == nil { + t.Fatal("Plan: returned nil plan") + } + if len(plan.Actions) != 1 { + t.Fatalf("Plan: expected 1 action, got %d", len(plan.Actions)) + } + if plan.Actions[0].Action != "create" { + t.Errorf("Plan: expected action 'create', got %q", plan.Actions[0].Action) + } + if plan.Actions[0].Resource.Name != "my-vpc" { + t.Errorf("Plan: expected resource name 'my-vpc', got %q", plan.Actions[0].Resource.Name) + } +} + +// TestStub_Apply_NoErrors asserts that driving ApplyPlanWithHooks with the +// stub provider on a plan with a create action returns an ApplyResult with +// no errors. +func TestStub_Apply_NoErrors(t *testing.T) { + p := stubprovider.New() + plan := &interfaces.IaCPlan{ + ID: "test-plan", + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{Name: "my-vpc", Type: "infra.vpc"}}, + }, + } + result, err := wfctlhelpers.ApplyPlanWithHooks(context.Background(), p, plan, wfctlhelpers.ApplyPlanHooks{}) + if err != nil { + t.Fatalf("ApplyPlanWithHooks: unexpected error: %v", err) + } + if result == nil { + t.Fatal("ApplyPlanWithHooks: returned nil result") + } + if len(result.Errors) != 0 { + t.Errorf("ApplyPlanWithHooks: expected no errors, got: %v", result.Errors) + } +} + +// TestStub_Destroy_ReturnsRefs asserts that Destroy returns the refs as +// Destroyed names. +func TestStub_Destroy_ReturnsRefs(t *testing.T) { + p := stubprovider.New() + refs := []interfaces.ResourceRef{ + {Name: "my-vpc", Type: "infra.vpc", ProviderID: "do-vpc-123"}, + {Name: "my-db", Type: "infra.database", ProviderID: "do-db-456"}, + } + result, err := p.Destroy(context.Background(), refs) + if err != nil { + t.Fatalf("Destroy: unexpected error: %v", err) + } + if result == nil { + t.Fatal("Destroy: returned nil result") + } + if len(result.Destroyed) != 2 { + t.Fatalf("Destroy: expected 2 destroyed, got %d", len(result.Destroyed)) + } + names := map[string]bool{} + for _, n := range result.Destroyed { + names[n] = true + } + if !names["my-vpc"] || !names["my-db"] { + t.Errorf("Destroy: expected 'my-vpc' and 'my-db' in destroyed, got %v", result.Destroyed) + } +} + +// TestStub_DetectDrift_NotDrifted asserts that DetectDrift returns results +// with Drifted:false for all refs. +func TestStub_DetectDrift_NotDrifted(t *testing.T) { + p := stubprovider.New() + refs := []interfaces.ResourceRef{ + {Name: "my-vpc", Type: "infra.vpc"}, + {Name: "my-db", Type: "infra.database"}, + } + results, err := p.DetectDrift(context.Background(), refs) + if err != nil { + t.Fatalf("DetectDrift: unexpected error: %v", err) + } + if len(results) != 2 { + t.Fatalf("DetectDrift: expected 2 results, got %d", len(results)) + } + for _, r := range results { + if r.Drifted { + t.Errorf("DetectDrift: expected Drifted:false for %q, got true", r.Name) + } + if r.Class != interfaces.DriftClassInSync { + t.Errorf("DetectDrift: expected Class DriftClassInSync for %q, got %q", r.Name, r.Class) + } + } +} + +// TestStub_Plan_UpdateAction asserts that Plan produces an "update" action +// when a resource is present in both desired and current state. +func TestStub_Plan_UpdateAction(t *testing.T) { + p := stubprovider.New() + desired := []interfaces.ResourceSpec{ + {Name: "my-vpc", Type: "infra.vpc"}, + } + current := []interfaces.ResourceState{ + {Name: "my-vpc", Type: "infra.vpc", ProviderID: "do-vpc-111"}, + } + plan, err := p.Plan(context.Background(), desired, current) + if err != nil { + t.Fatalf("Plan: unexpected error: %v", err) + } + if len(plan.Actions) != 1 { + t.Fatalf("Plan: expected 1 action, got %d", len(plan.Actions)) + } + if plan.Actions[0].Action != "update" { + t.Errorf("Plan: expected action 'update', got %q", plan.Actions[0].Action) + } +} + +// TestStub_Plan_DeleteAction asserts that Plan produces a "delete" action +// when a resource is present in current state but absent from desired. +func TestStub_Plan_DeleteAction(t *testing.T) { + p := stubprovider.New() + current := []interfaces.ResourceState{ + {Name: "old-vpc", Type: "infra.vpc", ProviderID: "do-vpc-999"}, + } + plan, err := p.Plan(context.Background(), nil, current) + if err != nil { + t.Fatalf("Plan: unexpected error: %v", err) + } + if len(plan.Actions) != 1 { + t.Fatalf("Plan: expected 1 action, got %d", len(plan.Actions)) + } + if plan.Actions[0].Action != "delete" { + t.Errorf("Plan: expected action 'delete', got %q", plan.Actions[0].Action) + } + if plan.Actions[0].Resource.Name != "old-vpc" { + t.Errorf("Plan: expected resource name 'old-vpc', got %q", plan.Actions[0].Resource.Name) + } +} diff --git a/iac/wfctlhelpers/desired_hash.go b/iac/wfctlhelpers/desired_hash.go new file mode 100644 index 00000000..65f028fe --- /dev/null +++ b/iac/wfctlhelpers/desired_hash.go @@ -0,0 +1,96 @@ +package wfctlhelpers + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/jitsubst" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// DesiredStateHash returns a stable SHA-256 hex digest of the canonical +// desired-state inputs: specs with ${MODULE.id} refs collapsed to current +// state ProviderIDs, then sorted by name and JSON-serialised. +// +// IMPORTANT — env/secret refs are preserved verbatim (NOT resolved): +// ${ENV_VAR} and ${secret.*} placeholders hash as their literal template +// strings. This is deliberate: these values may differ between processes +// (secret-gen vars, os.Getenv) but must produce the same hash at plan +// time and at apply time. Env drift is tracked separately via the +// plan's InputSnapshot / InputDriftReport mechanism, not the hash. +// Collapsing env refs here caused plan-hash ≠ apply-hash regressions +// (TestParseInfraResourceSpecs_Preserves*). +// +// Only ${MODULE.field} refs whose source is present in `current` are +// collapsed — these are stable ProviderIDs that will not change between +// plan and apply for the same desired configuration. +// +// cfg and env are reserved for a future extension that threads cfg+env +// through buildResolvedSecretsFromState to achieve full CLI parity for +// infra_output-typed secrets. They are intentionally unused today. +// +// The empty-specs case hashes "[]" (not the empty string); the +// returned "hash-error" string is the unreachable marshal-failure sentinel. +func DesiredStateHash(cfg *config.WorkflowConfig, desired []interfaces.ResourceSpec, current []interfaces.ResourceState, env string) string { + _ = cfg // reserved: see godoc + _ = env // reserved: see godoc + + // Step 1: build syncedOutputs from current state. + // Maps module-name → {"id": providerID, } + syncedOutputs := buildHashSyncedOutputs(current) + + // Step 2: collapse only ${MODULE.field} refs that are resolvable 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 at apply time for plan↔apply stability. + 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 { + // Malformed ref — use unresolved spec; hash will differ from a + // clean spec (deterministic for same bad input). + r = spec + } + resolved = append(resolved, r) + } + + // Step 3: sort by name for stable ordering regardless of caller order. + sort.Slice(resolved, func(i, j int) bool { + return resolved[i].Name < resolved[j].Name + }) + + // Step 4: SHA-256 over the canonical JSON. + data, err := json.Marshal(resolved) + if err != nil { + // Unreachable for YAML-decoded ResourceSpec structs, but return a + // non-matchable sentinel distinct from any valid hash so callers + // treat it as "hash unavailable" rather than "empty desired set". + return "hash-error" + } + sum := sha256.Sum256(data) + return fmt.Sprintf("%x", sum) +} + +// buildHashSyncedOutputs converts current ResourceState slice into the +// module-output map consumed by jitsubst.TryResolveSpec. The canonical +// "id" key is the ProviderID; any other resource Outputs are also included. +// This mirrors cmd/wfctl's buildSyncedOutputsFromState. +func buildHashSyncedOutputs(states []interfaces.ResourceState) map[string]map[string]any { + out := make(map[string]map[string]any, len(states)) + for i := range states { + s := &states[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 + } + out[s.Name] = m + } + return out +} diff --git a/iac/wfctlhelpers/desired_hash_test.go b/iac/wfctlhelpers/desired_hash_test.go new file mode 100644 index 00000000..831b0be0 --- /dev/null +++ b/iac/wfctlhelpers/desired_hash_test.go @@ -0,0 +1,169 @@ +package wfctlhelpers_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/handler" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestDesiredStateHash_Determinism asserts that calling DesiredStateHash twice +// with the same inputs returns the same value (the in-process plan/re-plan +// invariant). +func TestDesiredStateHash_Determinism(t *testing.T) { + desired := []interfaces.ResourceSpec{ + {Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, + {Name: "db1", Type: "infra.database", Config: map[string]any{"size": "s"}}, + } + current := []interfaces.ResourceState{ + {Name: "vpc1", ProviderID: "do-vpc-111"}, + } + + h1 := wfctlhelpers.DesiredStateHash(nil, desired, current, "staging") + h2 := wfctlhelpers.DesiredStateHash(nil, desired, current, "staging") + if h1 == "" { + t.Fatal("DesiredStateHash returned empty string") + } + if h1 != h2 { + t.Errorf("DesiredStateHash is not deterministic: %q != %q", h1, h2) + } +} + +// TestDesiredStateHash_ModuleRefCollapses asserts that a ${MODULE.id} ref in a +// spec's config is resolved to the ProviderID from current state before hashing +// (matching the CLI resolve+hash path). +func TestDesiredStateHash_ModuleRefCollapses(t *testing.T) { + // desired spec references vpc1's id — must collapse to "do-vpc-111" + 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"}, + } + + // Hash with the state that can resolve ${vpc1.id} + h := wfctlhelpers.DesiredStateHash(nil, desired, current, "staging") + // Hash with the ref pre-resolved to prove it produces the same digest + desiredResolved := []interfaces.ResourceSpec{ + {Name: "db1", Type: "infra.database", Config: map[string]any{ + "vpc_id": "do-vpc-111", + }}, + } + hResolved := wfctlhelpers.DesiredStateHash(nil, desiredResolved, nil, "staging") + + if h == "" || hResolved == "" { + t.Fatal("DesiredStateHash returned empty string") + } + if h != hResolved { + t.Errorf("${vpc1.id} not collapsed: hash with ref=%q, hash pre-resolved=%q", h, hResolved) + } +} + +// TestDesiredStateHash_ChangesOnFieldChange asserts that the hash changes when +// a spec field changes. +func TestDesiredStateHash_ChangesOnFieldChange(t *testing.T) { + base := []interfaces.ResourceSpec{ + {Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, + } + modified := []interfaces.ResourceSpec{ + {Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "sfo3"}}, + } + + h1 := wfctlhelpers.DesiredStateHash(nil, base, nil, "staging") + h2 := wfctlhelpers.DesiredStateHash(nil, modified, nil, "staging") + if h1 == "" || h2 == "" { + t.Fatal("DesiredStateHash returned empty string") + } + if h1 == h2 { + t.Error("DesiredStateHash did not change when spec field changed") + } +} + +// TestDesiredStateHash_EmptySpecsIsStable asserts that an empty desired set +// returns a non-empty, stable hash (not "" sentinel — the delete-all case). +func TestDesiredStateHash_EmptySpecsIsStable(t *testing.T) { + h := wfctlhelpers.DesiredStateHash(nil, nil, nil, "") + if h == "" { + t.Error("DesiredStateHash(empty) returned empty string — should be sha256([])") + } + h2 := wfctlhelpers.DesiredStateHash(nil, nil, nil, "") + if h != h2 { + t.Errorf("empty hash not deterministic: %q != %q", h, h2) + } +} + +// TestDesiredStateHash_SortOrderIndependent asserts that specs in different +// orders produce the same hash. +func TestDesiredStateHash_SortOrderIndependent(t *testing.T) { + a := []interfaces.ResourceSpec{ + {Name: "aaa", Type: "infra.vpc"}, + {Name: "bbb", Type: "infra.database"}, + } + b := []interfaces.ResourceSpec{ + {Name: "bbb", Type: "infra.database"}, + {Name: "aaa", Type: "infra.vpc"}, + } + h1 := wfctlhelpers.DesiredStateHash(nil, a, nil, "") + h2 := wfctlhelpers.DesiredStateHash(nil, b, nil, "") + if h1 != h2 { + 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 index 3bb5e0fa..aec7cdc9 100644 --- a/module/infra_admin.go +++ b/module/infra_admin.go @@ -49,6 +49,8 @@ import ( "net/http" "os" "strconv" + "strings" + "sync" "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/config" @@ -116,6 +118,29 @@ type InfraAdminConfig struct { // 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 @@ -132,6 +157,19 @@ type InfraAdmin struct { 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 @@ -170,9 +208,10 @@ func NewInfraAdmin(name string, cfg map[string]any) modular.Module { _ = json.Unmarshal(raw, &c) } return &InfraAdmin{ - name: name, - config: c, - providers: map[string]interfaces.IaCProvider{}, + name: name, + config: c, + providers: map[string]interfaces.IaCProvider{}, + providerMu: map[string]*sync.Mutex{}, } } @@ -197,6 +236,9 @@ func (m *InfraAdmin) Dependencies() []string { 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 } @@ -225,6 +267,9 @@ func (m *InfraAdmin) RequiresServices() []modular.ServiceDependency { 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}) } @@ -248,6 +293,16 @@ func (m *InfraAdmin) ProvidesServices() []modular.ServiceProvider { return nil } 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 { @@ -311,13 +366,27 @@ func (m *InfraAdmin) Init(app modular.Application) error { m.auth = authMw } - // Per-provider IaCProvider handles. + // 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{} } // Populate providerTypeByModule from the loaded WorkflowConfig @@ -376,21 +445,100 @@ func (m *InfraAdmin) populateProviderTypes(app modular.Application) error { 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] - if mod.Type != "iac.provider" { - continue + 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)) } - modCfg := config.ExpandEnvInMap(mod.Config) - pt, _ := modCfg["provider"].(string) - if pt == "" { - continue + } + 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) + } } - m.providerTypeByModule[mod.Name] = pt + 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 @@ -425,7 +573,7 @@ func (m *InfraAdmin) Start(ctx context.Context) error { mws = append(mws, m.secHdrs) } - // Typed API routes. + // Typed API routes (reads — no bearer requirement beyond auth middleware). apiRoutes := []struct { method string path string @@ -443,6 +591,39 @@ func (m *InfraAdmin) Start(ctx context.Context) error { 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 @@ -505,6 +686,21 @@ func (m *InfraAdmin) Start(ctx context.Context) error { }}, }, }}, + // 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": []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 { @@ -560,6 +756,20 @@ func readAdminBody(r *http.Request) ([]byte, error) { // 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 @@ -582,13 +792,24 @@ func (m *InfraAdmin) auditAccess(r *http.Request, action string, ev *adminpb.Adm // audit log's `result` value. Empty error → "ok"; non-empty → // "denied" (the handler library's primary refusal path is // authz-default-deny, so "denied" is the most informative -// label for v1; future v1.1 might split into "denied"/"error" -// /"not_found" but the proto field is a free-form string). +// auditResultFor classifies an Output.error string into the +// three-way audit result: "ok" (no error), "denied" (authz/evidence +// rejection), or "error" (provider/backend failure). The +// discrimination is substring-based on the error prefix, which the +// handler library follows consistently. func auditResultFor(errMsg string) string { if errMsg == "" { return "ok" } - return "denied" + // 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" } // nowUnix is a package-level var so tests can substitute a fixed @@ -815,3 +1036,134 @@ func writeProtoMsg(w http.ResponseWriter, msg proto.Message) { w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } + +// ── 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 + } + } + // handlers never return non-nil error (errors go to out.Error per proto tag-100 pattern) + out, _ := handler.PlanResource(r.Context(), m.state, m.providers, m.wfCfg, m.desiredSpecs, &in) //nolint:errcheck + writeProtoMsg(w, out) + m.auditAccess(r, "plan", in.GetEvidence(), auditResultFor(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, _ := handler.ApplyResource(r.Context(), m.state, m.providers, m.authz, subject, m.wfCfg, m.desiredSpecs, &in) //nolint:errcheck // errors go to out.Error + writeProtoMsg(w, out) + m.auditAccess(r, "apply", in.GetEvidence(), auditResultFor(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, _ := handler.DestroyResource(r.Context(), m.providers, m.authz, subject, &in) //nolint:errcheck // errors go to out.Error + writeProtoMsg(w, out) + m.auditAccess(r, "destroy", in.GetEvidence(), auditResultFor(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_mutation_integration_test.go b/module/infra_admin_mutation_integration_test.go new file mode 100644 index 00000000..affc254e --- /dev/null +++ b/module/infra_admin_mutation_integration_test.go @@ -0,0 +1,374 @@ +package module + +// MutationIntegration tests wire the infra.admin module end-to-end without +// BuildFromConfig (per ADR-0003→v4 lesson that BuildFromConfig makes test +// setup brittle — manual wiring is explicit about what's under test). +// +// Wiring: auth stub + recordingStateStore + stubprovider.New() (T2) + +// stubEnforcer (I-2) + infra.admin module + audit log. +// +// Assertions per T10 spec: +// (1) state store gains the resource after apply (C-1: handler saves state) +// (2) AdminAuditEntry{action:apply, result:ok} written to audit log +// (3) applied[] in response body non-empty (C-2: non-empty desiredSpecs) +// After DestroyResource: AdminAuditEntry{action:destroy, result:ok} + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/admin/handler" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/iac/stubprovider" + "github.com/GoCodeAlone/workflow/interfaces" + "google.golang.org/protobuf/encoding/protojson" +) + +// recordingStateStore captures SaveResource calls so integration tests +// can assert assertion (1): state store gains the resource. +type recordingStateStore struct { + mu sync.Mutex + resources []interfaces.ResourceState +} + +func (s *recordingStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]interfaces.ResourceState, len(s.resources)) + copy(out, s.resources) + return out, nil +} +func (s *recordingStateStore) GetResource(_ context.Context, name string) (*interfaces.ResourceState, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.resources { + if s.resources[i].Name == name { + r := s.resources[i] + return &r, nil + } + } + return nil, nil +} +func (s *recordingStateStore) SaveResource(_ context.Context, state interfaces.ResourceState) error { + s.mu.Lock() + defer s.mu.Unlock() + // Upsert: replace existing by name or append. + for i := range s.resources { + if s.resources[i].Name == state.Name { + s.resources[i] = state + return nil + } + } + s.resources = append(s.resources, state) + return nil +} +func (s *recordingStateStore) DeleteResource(_ context.Context, name string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.resources { + if s.resources[i].Name == name { + s.resources = append(s.resources[:i], s.resources[i+1:]...) + return nil + } + } + return nil +} +func (s *recordingStateStore) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { return nil } +func (s *recordingStateStore) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) { + return nil, nil +} +func (s *recordingStateStore) Lock(_ context.Context, _ string, _ time.Duration) (interfaces.IaCLockHandle, error) { + return nil, nil +} +func (s *recordingStateStore) Close() error { return nil } + +// integrationEnforcer always grants access (integration happy-path). +// calls is bumped atomically so tests can assert Enforce was actually +// invoked (per spec-reviewer T10 non-blocking note). +type integrationEnforcer struct { + calls int64 +} + +func (e *integrationEnforcer) Enforce(_, _, _ string, _ ...string) (bool, error) { + atomic.AddInt64(&e.calls, 1) + return true, nil +} + +// mutationIntegrationApp manually wires the infra.admin module with: +// - auth stub + authz stub enforcer (I-2) +// - recordingStateStore (C-1 assertion) +// - stub iac.provider (T2: stubprovider.New()) +// - WorkflowConfig with one infra.database resource (C-2: non-empty desiredSpecs) +func mutationIntegrationApp(t *testing.T, auditPath string) (*withConfigSectionApp, *InfraAdmin, *recordingStateStore, *integrationEnforcer) { + t.Helper() + providerName := "stub-provider" + + // Base app with auth + workflow config section. + app, _, _ := newAppWithWorkflowSection(t, "stub") + + // WorkflowConfig: one iac.provider + one infra.database (C-2: non-empty + // desiredSpecs so plan produces real actions and applied[] is non-empty). + section := &wfConfigSection{cfg: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: providerName, Type: "iac.provider", Config: map[string]any{"provider": "stub"}}, + {Name: "db1", Type: "infra.database", Config: map[string]any{"size": "s", "engine": "pg14"}}, + }, + }} + app.services["__workflow_section__"] = section + // withConfigSectionApp caches the section at construction time — replace it + // so GetConfigSection("workflow") returns the new config. + app.section = section + + // Auth stub + authz stub enforcer (I-2). + auth := &authMwStub{name: "auth"} + enforcer := &integrationEnforcer{} + mustRegister(t, app, "auth", auth) + mustRegister(t, app, "my-authz", enforcer) + + // Stub provider registered under its module name (per T2). + mustRegister(t, app, providerName, stubprovider.New()) + + // Recording state store so we can assert assertion (1). + store := &recordingStateStore{} + mustRegister(t, app, "iac-state", store) + + cfg := InfraAdminConfig{ + RoutePrefix: "/api/infra-admin", + AssetPrefix: "/admin/infra-admin", + StateModule: "iac-state", + HTTPModule: "http-router", + SecurityHeadersModule: "security-headers", + AuthModule: "auth", + AuthzModule: "my-authz", // I-2: wire authz so Enforce is called + ProviderModules: []string{providerName}, + AccessLogPath: auditPath, + } + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + return app, m, store, enforcer +} + +// TestMutationIntegration_Apply verifies the end-to-end apply path: +// - POST /plan → plan_id + desired_hash non-empty (I-1) +// - POST /apply → applied[] non-empty (C-2), state store gains resource (C-1), +// audit entry {action:apply, result:ok} (assertion 2) +func TestMutationIntegration_Apply(t *testing.T) { + dir := t.TempDir() + auditPath := filepath.Join(dir, "audit.jsonl") + + app, m, store, enforcer := mutationIntegrationApp(t, auditPath) + 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) + } + + // Step 1: Plan. + planBody := `{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"operator"}}` + planReq := httptest.NewRequest(http.MethodPost, "/api/infra-admin/plan", + bytes.NewReader([]byte(planBody))) + planReq.Header.Set("Authorization", "Bearer test-token") + ctx := context.WithValue(planReq.Context(), authClaimsContextKey, map[string]any{"sub": "operator"}) + planReq = planReq.WithContext(ctx) + planRec := httptest.NewRecorder() + m.router.ServeHTTP(planRec, planReq) + if planRec.Code != http.StatusOK { + t.Fatalf("plan: status %d; body=%s", planRec.Code, planRec.Body.String()) + } + + var planOut adminpb.AdminPlanOutput + if err := protojson.Unmarshal(planRec.Body.Bytes(), &planOut); err != nil { + t.Fatalf("plan: decode response: %v\n%s", err, planRec.Body.String()) + } + + // Assertion I-1: plan_id and desired_hash must be non-empty. + if planOut.GetPlanId() == "" { + t.Error("plan_id must be non-empty") + } + if planOut.GetDesiredHash() == "" { + t.Error("desired_hash must be non-empty") + } + if planOut.GetError() != "" { + t.Fatalf("plan: unexpected error: %s", planOut.GetError()) + } + + // Step 2: Apply with the hash from the plan response. + applyPayload := mustMarshal(t, map[string]any{ + "plan_id": planOut.GetPlanId(), + "desired_hash": planOut.GetDesiredHash(), + "evidence": map[string]any{"authz_checked": true, "authz_allowed": true, "subject": "operator"}, + }) + applyReq := httptest.NewRequest(http.MethodPost, "/api/infra-admin/apply", + bytes.NewReader(applyPayload)) + applyReq.Header.Set("Authorization", "Bearer test-token") + applyCtx := context.WithValue(applyReq.Context(), authClaimsContextKey, map[string]any{"sub": "operator"}) + applyReq = applyReq.WithContext(applyCtx) + applyRec := httptest.NewRecorder() + m.router.ServeHTTP(applyRec, applyReq) + + if applyRec.Code != http.StatusOK { + t.Fatalf("apply: status %d; body=%s", applyRec.Code, applyRec.Body.String()) + } + + var applyOut adminpb.AdminApplyOutput + if err := protojson.Unmarshal(applyRec.Body.Bytes(), &applyOut); err != nil { + t.Fatalf("apply: decode response: %v\n%s", err, applyRec.Body.String()) + } + if applyOut.GetError() != "" { + t.Errorf("apply: unexpected error: %s", applyOut.GetError()) + } + + // Assertion (3): applied[] non-empty (C-2 fix: WorkflowConfig has db1 infra.database). + if len(applyOut.GetApplied()) == 0 { + t.Error("apply: applied[] should be non-empty — desiredSpecs has 1 infra.database resource") + } + + // Assertion (1): state store gains the resource (C-1 fix: handler now calls SaveResource). + stateRows, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("apply: ListResources: %v", err) + } + if len(stateRows) == 0 { + t.Error("apply: state store should have at least 1 resource after apply") + } else { + found := false + for _, row := range stateRows { + if row.Name == "db1" { + found = true + break + } + } + if !found { + t.Errorf("apply: state store missing 'db1'; rows: %v", stateRows) + } + } + + // Assertion (2): audit entry with action:apply result:ok. + assertAuditEntry(t, auditPath, "apply", "ok") + + // I-2 verification: Enforce was invoked (spec-reviewer T10 non-blocking note). + if atomic.LoadInt64(&enforcer.calls) == 0 { + t.Error("authz Enforce was never called during apply — authz module not wired correctly") + } +} + +// TestMutationIntegration_Destroy verifies the end-to-end destroy path: +// POST /destroy with confirm_hash → destroyed[] + audit entry ok +func TestMutationIntegration_Destroy(t *testing.T) { + dir := t.TempDir() + auditPath := filepath.Join(dir, "audit.jsonl") + + app, m, _, _ := mutationIntegrationApp(t, auditPath) + 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) + } + + // Compute confirm_hash from refs for TOCTOU gate (T7 IMPORTANT-1 / I-3). + refs := []*adminpb.AdminResourceRef{ + {Name: "vpc1", Type: "infra.vpc"}, + {Name: "db1", Type: "infra.database"}, + } + confirmHash := handler.HashDestroyRefs(refs) + destroyPayload := mustMarshal(t, map[string]any{ + "refs": []map[string]string{{"name": "vpc1", "type": "infra.vpc"}, {"name": "db1", "type": "infra.database"}}, + "confirm_hash": confirmHash, + "evidence": map[string]any{"authz_checked": true, "authz_allowed": true, "subject": "operator"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/destroy", + bytes.NewReader(destroyPayload)) + req.Header.Set("Authorization", "Bearer test-token") + ctx := context.WithValue(req.Context(), authClaimsContextKey, map[string]any{"sub": "operator"}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("destroy: status %d; body=%s", rec.Code, rec.Body.String()) + } + var out adminpb.AdminDestroyOutput + if err := protojson.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("destroy: decode: %v\n%s", err, rec.Body.String()) + } + if out.GetError() != "" { + t.Errorf("destroy: unexpected error: %s", out.GetError()) + } + if len(out.GetDestroyed()) != 2 { + t.Errorf("destroy: expected 2 destroyed, got %d: %v", len(out.GetDestroyed()), out.GetDestroyed()) + } + + // Assertion (2): audit entry with action:destroy result:ok. + assertAuditEntry(t, auditPath, "destroy", "ok") +} + +// assertAuditEntry scans the audit JSONL file for an entry matching +// the given action + result. Fails the test if none is found. +func assertAuditEntry(t *testing.T, auditPath, action, result string) { + t.Helper() + f, err := os.Open(auditPath) + if err != nil { + t.Fatalf("open audit log %q: %v", auditPath, err) + } + defer f.Close() //nolint:errcheck + + type entry struct { + Action string `json:"action"` + Result string `json:"result"` + } + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + var e entry + if err := json.Unmarshal(line, &e); err != nil { + continue + } + if strings.EqualFold(e.Action, action) && strings.EqualFold(e.Result, result) { + return // found + } + } + t.Errorf("audit log %q missing entry {action:%q, result:%q}", auditPath, action, result) +} + +// mustRegister is a test helper that calls app.RegisterService and +// fatalf on error, replacing _ = app.RegisterService(...) patterns. +func mustRegister(t *testing.T, app interface { + RegisterService(string, any) error +}, name string, svc any) { + t.Helper() + if err := app.RegisterService(name, svc); err != nil { + t.Fatalf("setup: RegisterService(%q): %v", name, err) + } +} + +// mustMarshal is a test helper that calls json.Marshal and fatalf on error. +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("setup: json.Marshal: %v", err) + } + return data +} + +// Compile-time: interfaces.IaCProvider satisfied by stubprovider.Provider. +var _ interfaces.IaCProvider = (*stubprovider.Provider)(nil) diff --git a/module/infra_admin_test.go b/module/infra_admin_test.go index e5966232..bf2f33d7 100644 --- a/module/infra_admin_test.go +++ b/module/infra_admin_test.go @@ -169,6 +169,9 @@ func standardCfg() InfraAdminConfig { HTTPModule: "http-router", SecurityHeadersModule: "security-headers", ProviderModules: []string{"do-provider"}, + // T4: test-only insecure mode; auth is exercised separately + // via standardAuthCfg() + newAuthEnabledApp(). + AllowUnauthenticated: true, } } @@ -256,8 +259,9 @@ func TestInfraAdmin_Init_AuditFailureIsFatal(t *testing.T) { } // TestInfraAdmin_Start_Fires3ContributionPipelines pins the -// design's Start contract: exactly 3 engine.TriggerWorkflow calls -// fire, with the expected pipeline names + contribution payloads. +// design's Start contract: the expected engine.TriggerWorkflow calls +// fire for all registered admin-plugin contribution pipelines. +// Updated to 4 with the T12 audit-viewer (register-infra-admin-actions). func TestInfraAdmin_Start_Fires3ContributionPipelines(t *testing.T) { app, engine, _ := newAppWithWorkflowSection(t, "digitalocean") m := NewInfraAdmin("infra-admin", configToMap(t, standardCfg())).(*InfraAdmin) @@ -267,13 +271,11 @@ func TestInfraAdmin_Start_Fires3ContributionPipelines(t *testing.T) { if err := m.Start(context.Background()); err != nil { t.Fatalf("Start: %v", err) } - if len(engine.triggers) != 3 { - t.Fatalf("TriggerWorkflow calls = %d, want 3", len(engine.triggers)) - } wantNames := map[string]bool{ "pipeline:register-infra-admin-resources": false, "pipeline:register-infra-admin-resource-detail": false, "pipeline:register-infra-admin-new-resource": false, + "pipeline:register-infra-admin-actions": false, // T12 audit-viewer } for _, tr := range engine.triggers { if _, ok := wantNames[tr.WorkflowType]; ok { @@ -287,6 +289,9 @@ func TestInfraAdmin_Start_Fires3ContributionPipelines(t *testing.T) { t.Errorf("expected trigger for %s, not fired", name) } } + if len(engine.triggers) != len(wantNames) { + t.Errorf("TriggerWorkflow calls = %d, want %d", len(engine.triggers), len(wantNames)) + } } func TestInfraAdmin_Start_PropagatesEngineFailure(t *testing.T) { @@ -930,3 +935,437 @@ func configToMap(t *testing.T, cfg InfraAdminConfig) map[string]any { } 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 for subject viewer", "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) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status %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. + 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.StatusOK { + t.Fatalf("apply: want 200 with error body, got %d", applyRec.Code) + } + 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.StatusOK { + t.Fatalf("destroy: want 200 with error body, got %d", destroyRec.Code) + } + if !strings.Contains(destroyRec.Body.String(), "denied") { + t.Errorf("viewer destroy should be denied by server-side Enforcer; body=%s", destroyRec.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 for subject viewer", + "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) + } + } +} diff --git a/plugins/all/all.go b/plugins/all/all.go index 25b13de2..512325e5 100644 --- a/plugins/all/all.go +++ b/plugins/all/all.go @@ -67,11 +67,18 @@ type PluginLoader interface { LoadPlugin(p plugin.EnginePlugin) error } -// DefaultPlugins returns the standard set of built-in engine plugins. +// scenarioExtras holds additional plugins that are only linked in when the +// "scenario_stub" build tag is active (see extras_stub.go). The init() +// function in that file appends to this slice; without the tag it remains +// nil and DefaultPlugins() returns only the base set. +var scenarioExtras []plugin.EnginePlugin + +// DefaultPlugins returns the standard set of built-in engine plugins, +// extended by any scenario-specific extras registered via build tags. // The slice is freshly allocated on each call so callers may safely append // custom plugins without affecting other callers. func DefaultPlugins() []plugin.EnginePlugin { - return []plugin.EnginePlugin{ + base := []plugin.EnginePlugin{ pluginlicense.New(), pluginconfigprovider.New(), pluginhttp.New(), @@ -105,6 +112,7 @@ func DefaultPlugins() []plugin.EnginePlugin { pluginscanner.New(), pluginactors.New(), } + return append(base, scenarioExtras...) } // LoadAll loads all default built-in plugins into the given engine. diff --git a/plugins/all/extras_base_test.go b/plugins/all/extras_base_test.go new file mode 100644 index 00000000..0f346670 --- /dev/null +++ b/plugins/all/extras_base_test.go @@ -0,0 +1,18 @@ +//go:build !scenario_stub + +package all + +import "testing" + +// TestDefaultPlugins_BaseExcludesStub asserts that without the +// "scenario_stub" build tag, DefaultPlugins() does NOT include the +// stub provider plugin. This guards against accidentally shipping the +// stub in production server builds. +func TestDefaultPlugins_BaseExcludesStub(t *testing.T) { + for _, p := range DefaultPlugins() { + if p.Name() == "stubprovider" { + t.Error("DefaultPlugins() contains 'stubprovider' in a non-scenario_stub build — stub must not appear in production") + return + } + } +} diff --git a/plugins/all/extras_stub.go b/plugins/all/extras_stub.go new file mode 100644 index 00000000..ea4f2ce5 --- /dev/null +++ b/plugins/all/extras_stub.go @@ -0,0 +1,9 @@ +//go:build scenario_stub + +package all + +import pluginstub "github.com/GoCodeAlone/workflow/plugins/stubprovider" + +func init() { + scenarioExtras = append(scenarioExtras, pluginstub.New()) +} diff --git a/plugins/all/extras_stub_test.go b/plugins/all/extras_stub_test.go new file mode 100644 index 00000000..af76a499 --- /dev/null +++ b/plugins/all/extras_stub_test.go @@ -0,0 +1,26 @@ +//go:build scenario_stub + +package all_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/plugins/all" +) + +// TestDefaultPlugins_ContainsStub asserts that when compiled with the +// "scenario_stub" build tag, DefaultPlugins() includes the stub provider +// plugin named "stubprovider". +func TestDefaultPlugins_ContainsStub(t *testing.T) { + plugins := all.DefaultPlugins() + for _, p := range plugins { + if p.Name() == "stubprovider" { + return // found + } + } + names := make([]string, 0, len(plugins)) + for _, p := range plugins { + names = append(names, p.Name()) + } + t.Errorf("DefaultPlugins() does not contain 'stubprovider'; plugins: %v", names) +} diff --git a/plugins/stubprovider/plugin.go b/plugins/stubprovider/plugin.go new file mode 100644 index 00000000..08c49977 --- /dev/null +++ b/plugins/stubprovider/plugin.go @@ -0,0 +1,110 @@ +// Package stubprovider provides a build-tagged loadable EnginePlugin that +// registers an in-process "stub" iac.provider module. This plugin is +// intended for scenario testing and integration tests only — it must NOT +// be linked into production server binaries. +// +// Loading is controlled by the "scenario_stub" build tag (see +// plugins/all/extras_stub.go). Without the tag, plugins/all.DefaultPlugins() +// does not include this plugin and cmd/server cannot load it. +// +// The registered module type is "iac.provider". When a config entry declares: +// +// type: iac.provider +// config: +// provider: stub +// +// the module's Init validates the provider field and logs a clear warning +// that no real cloud operations will occur. The module registers a +// stubprovider.Provider as a service under its own name so infra.admin can +// resolve it via app.GetService(, &iacProvider). +package stubprovider + +import ( + "fmt" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/iac/stubprovider" + "github.com/GoCodeAlone/workflow/plugin" +) + +// Plugin is the engine plugin that registers the stub iac.provider factory. +type Plugin struct { + plugin.BaseEnginePlugin +} + +// Compile-time assertion that Plugin implements plugin.EnginePlugin. +var _ plugin.EnginePlugin = (*Plugin)(nil) + +// New creates a new stub provider plugin. +func New() *Plugin { + return &Plugin{ + BaseEnginePlugin: plugin.BaseEnginePlugin{ + BaseNativePlugin: plugin.BaseNativePlugin{ + PluginName: "stubprovider", + PluginVersion: "0.1.0", + PluginDescription: "In-process stub iac.provider for scenario testing — no real cloud operations", + }, + Manifest: plugin.PluginManifest{ + Name: "stubprovider", + Version: "0.1.0", + Author: "GoCodeAlone", + Description: "In-process stub iac.provider for scenario testing — no real cloud operations", + ModuleTypes: []string{"iac.provider"}, + }, + }, + } +} + +// ModuleFactories returns a factory for "iac.provider" that produces a +// stubModule whose ProvidesServices registers a stubprovider.Provider. +func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { + return map[string]plugin.ModuleFactory{ + "iac.provider": func(name string, cfg map[string]any) modular.Module { + return &stubModule{name: name, cfg: cfg} + }, + } +} + +// stubModule is the in-process iac.provider module instantiated by the factory. +type stubModule struct { + name string + cfg map[string]any + provider *stubprovider.Provider +} + +// Name returns the module instance name. +func (m *stubModule) Name() string { return m.name } + +// Init validates config and prepares the stub provider. +// Returns an error when config.provider != "stub" to prevent +// accidentally loading this module as a real cloud provider. +func (m *stubModule) Init(app modular.Application) error { + pt, _ := m.cfg["provider"].(string) + if pt != "stub" { + return fmt.Errorf("iac/stubprovider: module %q: provider must be 'stub', got %q — this plugin cannot proxy real cloud providers", m.name, pt) + } + m.provider = stubprovider.New() + app.Logger().Warn("infra.admin stub provider: NO real cloud operations — demo/test only", "module", m.name) + return nil +} + +// ProvidesServices registers the stub provider under the module name so +// infra.admin can resolve it via app.GetService(m.name, &iacProvider). +func (m *stubModule) ProvidesServices() []modular.ServiceProvider { + if m.provider == nil { + // Not yet initialised; provider is set in Init. + // Return a pre-allocated instance so the DI graph can wire + // services before Init is called. + m.provider = stubprovider.New() + } + return []modular.ServiceProvider{ + { + Name: m.name, + Description: "stub iac.provider — in-process, no real cloud ops", + Instance: m.provider, + }, + } +} + +// RequiresServices returns nil — the stub provider has no service dependencies. +func (m *stubModule) RequiresServices() []modular.ServiceDependency { return nil } diff --git a/plugins/stubprovider/plugin_test.go b/plugins/stubprovider/plugin_test.go new file mode 100644 index 00000000..3bf40bf9 --- /dev/null +++ b/plugins/stubprovider/plugin_test.go @@ -0,0 +1,141 @@ +package stubprovider_test + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/interfaces" + pluginstub "github.com/GoCodeAlone/workflow/plugins/stubprovider" +) + +// TestPlugin_ModuleFactories asserts the plugin registers "iac.provider". +func TestPlugin_ModuleFactories(t *testing.T) { + p := pluginstub.New() + factories := p.ModuleFactories() + if factories == nil { + t.Fatal("ModuleFactories returned nil") + } + factory, ok := factories["iac.provider"] + if !ok { + t.Fatalf("expected 'iac.provider' in ModuleFactories, got keys: %v", keys(factories)) + } + if factory == nil { + t.Fatal("factory for 'iac.provider' is nil") + } +} + +// TestPlugin_Module_ProvidesIaCProvider creates an iac.provider module via the +// factory, calls ProvidesServices(), and asserts one of the entries satisfies +// interfaces.IaCProvider. +func TestPlugin_Module_ProvidesIaCProvider(t *testing.T) { + p := pluginstub.New() + factory := p.ModuleFactories()["iac.provider"] + + mod := factory("stub-provider", map[string]any{"provider": "stub"}) + if mod == nil { + t.Fatal("factory returned nil module") + } + if mod.Name() != "stub-provider" { + t.Errorf("module Name = %q, want 'stub-provider'", mod.Name()) + } + + sa, ok := mod.(modular.ServiceAware) + if !ok { + t.Fatalf("module does not implement modular.ServiceAware; got %T", mod) + } + services := sa.ProvidesServices() + if len(services) == 0 { + t.Fatal("ProvidesServices returned empty slice") + } + + var foundProvider interfaces.IaCProvider + for _, svc := range services { + if p, ok := svc.Instance.(interfaces.IaCProvider); ok { + foundProvider = p + break + } + } + if foundProvider == nil { + t.Fatal("none of ProvidesServices entries implements interfaces.IaCProvider") + } +} + +// TestPlugin_Module_NonStubProviderErrors asserts that a module configured +// with provider != "stub" returns an error from Init. +func TestPlugin_Module_NonStubProviderErrors(t *testing.T) { + p := pluginstub.New() + factory := p.ModuleFactories()["iac.provider"] + mod := factory("bad-provider", map[string]any{"provider": "digitalocean"}) + + app := modular.NewStdApplication(nil, nopLogger{}) + err := mod.Init(app) + if err == nil { + t.Fatal("Init with provider=digitalocean should return an error, got nil") + } +} + +// TestPlugin_Module_StubProviderInits asserts that a module configured +// with provider=stub initialises without error. +func TestPlugin_Module_StubProviderInits(t *testing.T) { + p := pluginstub.New() + factory := p.ModuleFactories()["iac.provider"] + mod := factory("my-stub", map[string]any{"provider": "stub"}) + + app := modular.NewStdApplication(nil, nopLogger{}) + if err := mod.Init(app); err != nil { + t.Fatalf("Init with provider=stub should not error: %v", err) + } +} + +// TestPlugin_Module_StubProviderApply exercises the resolved IaCProvider +// end-to-end via a Plan call to prove the service wire-up actually works. +func TestPlugin_Module_StubProviderApply(t *testing.T) { + p := pluginstub.New() + factory := p.ModuleFactories()["iac.provider"] + mod := factory("my-stub", map[string]any{"provider": "stub"}) + + app := modular.NewStdApplication(nil, nopLogger{}) + if err := mod.Init(app); err != nil { + t.Fatalf("Init: %v", err) + } + + sa := mod.(modular.ServiceAware) + var prov interfaces.IaCProvider + for _, svc := range sa.ProvidesServices() { + if ip, ok := svc.Instance.(interfaces.IaCProvider); ok { + prov = ip + break + } + } + if prov == nil { + t.Fatal("no IaCProvider service after Init") + } + + plan, err := prov.Plan(context.Background(), []interfaces.ResourceSpec{ + {Name: "vpc1", Type: "infra.vpc"}, + }, nil) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if len(plan.Actions) == 0 || plan.Actions[0].Action != "create" { + t.Errorf("expected plan with create action, got %+v", plan.Actions) + } +} + +// keys is a test helper that returns the map keys as a slice for diagnostics. +func keys[V any](m map[string]V) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks +} + +// nopLogger satisfies modular.Logger for tests. +type nopLogger struct{} + +func (nopLogger) Debug(string, ...any) {} +func (nopLogger) Info(string, ...any) {} +func (nopLogger) Warn(string, ...any) {} +func (nopLogger) Error(string, ...any) {}