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) |
+ Subject |
+ Action |
+ Targets |
+ Result |
+ App context |
+
+
+
+
+
+ No audit entries found.
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
Plan
+
+
+ | Action | Resource | Type | Summary | Allow replace |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drift Check
+
+ | Resource | Type | Drifted | Class | Fields |
+
+
+
+
+
+
+
+