Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6fccecd
feat(iac/stubprovider): promote NoopProvider to a loadable package
intel352 May 31, 2026
1bbae48
feat(iac/admin/ui): mutation panels (plan/apply/destroy/drift)
intel352 May 31, 2026
86d4a50
feat(plugins/stubprovider): build-tagged loadable stub iac.provider
intel352 May 31, 2026
1ea9fc2
refactor(wfctlhelpers): promote DesiredStateHash (resolve+hash) for i…
intel352 May 31, 2026
88f7a63
test(iac/admin/proto): apply code-review nits F1/F2/F3
intel352 May 31, 2026
6080b1a
fix(iac/admin/ui): T11 spec-review I-1 + S-1 + S-2
intel352 May 31, 2026
1c06fb8
feat(module/infra_admin): #29 require auth_module + authz_module + su…
intel352 May 31, 2026
83594b7
fix(iac/admin/ui): code-review minor F1+F2 (T11 post-review)
intel352 May 31, 2026
e65815f
feat(iac/admin/handler): PlanResource + DriftCheckResource
intel352 May 31, 2026
2434c18
feat(iac/admin/handler): ApplyResource + DestroyResource (TOCTOU + al…
intel352 May 31, 2026
46fb5b0
feat(module/infra_admin): mutation routes + requireBearer + single-fl…
intel352 May 31, 2026
dfce864
test(module/infra_admin): security regression suite (CSRF/spoof/TOCTO…
intel352 May 31, 2026
7a4d11b
test(module/infra_admin): mutation integration (apply->state->audit) …
intel352 May 31, 2026
b3c226d
fix(iac/stubprovider): code-review + spec-review fixes (OBS-1 + findi…
intel352 May 31, 2026
3a7cf18
fix(wfctlhelpers): rename DesiredStateHash env param from _ to env (s…
intel352 May 31, 2026
baee0a4
test(iac/admin/ui): serve mutation panels + audit-viewer assets
intel352 May 31, 2026
869c33f
fix(iac/admin/handler): T6 spec-review fixes (FAIL-14 + FAIL-16)
intel352 May 31, 2026
beb90b2
fix(iac/admin/handler): T7 spec-review fixes (IMPORTANT-1 + IMPORTANT-2)
intel352 May 31, 2026
32912fc
fix(iac/admin/ui): T12 spec-review — setInterval uses fetchAndCache +…
intel352 May 31, 2026
9713cb5
fix(module): adapt tests for T7 confirm_hash gate + T12 4th contribution
intel352 May 31, 2026
bed2651
fix(module/infra_admin): infraSpecFromResolved: add DependsOn extract…
intel352 May 31, 2026
6d2210e
fix(T10+handler): C-1/C-2/I-1/I-2/I-3 spec-review fixes
intel352 May 31, 2026
139955d
test(iac/admin/ui): code-review nits — io.ReadAll + pin setInterval(f…
intel352 May 31, 2026
8b0603a
fix: code-reviewer minor findings (T6/T7/T9)
intel352 May 31, 2026
8a8c067
fix(T10): add Enforce call counter to integrationEnforcer (spec-revie…
intel352 May 31, 2026
7897e70
fix: code-reviewer minor findings (T3/T8)
intel352 May 31, 2026
e6460d0
fix(T10): code-reviewer minor findings F1/F2/F3
intel352 May 31, 2026
7159e3a
fix(wfctlhelpers): DesiredStateHash must NOT resolve env/secret refs
intel352 May 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 6 additions & 19 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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.
Expand Down
276 changes: 276 additions & 0 deletions iac/admin/handler/apply_resource.go
Original file line number Diff line number Diff line change
@@ -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)"
}
Loading
Loading