Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions iac/admin/handler/apply_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func ApplyResource(
) (*adminpb.AdminApplyOutput, error) {
// Gate 1: default-deny.
if msg := authzError(in.GetEvidence()); msg != "" {
return &adminpb.AdminApplyOutput{Error: msg}, nil
return &adminpb.AdminApplyOutput{Error: msg}, ErrAuthzDenied
}

// Gate 2: server-side RBAC (NOT the client's evidence.granted_permissions).
Expand All @@ -56,7 +56,9 @@ func ApplyResource(
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
// Generic denial — do NOT reflect the authenticated subject in the
// response body. Subject is captured by the module-layer audit log.
return &adminpb.AdminApplyOutput{Error: "apply: infra:apply denied"}, ErrAuthzDenied
}
}

Expand Down
18 changes: 9 additions & 9 deletions iac/admin/handler/apply_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package handler_test

import (
"context"
"errors"
"testing"

"github.com/GoCodeAlone/workflow/iac/admin/handler"
adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto"
"github.com/GoCodeAlone/workflow/iac/stubprovider"
"github.com/GoCodeAlone/workflow/interfaces"
)

Expand All @@ -25,7 +25,7 @@ func (e *testEnforcer) Enforce(sub, obj, act string, _ ...string) (bool, error)
// TestApplyResource_DefaultDeny asserts that evidence with checked=false
// returns a non-empty error (default-deny).
func TestApplyResource_DefaultDeny(t *testing.T) {
prov := stubprovider.New()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
desired := []interfaces.ResourceSpec{
{Name: "vpc1", Type: "infra.vpc"},
Expand All @@ -40,8 +40,8 @@ func TestApplyResource_DefaultDeny(t *testing.T) {
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 !errors.Is(err, handler.ErrAuthzDenied) {
t.Fatalf("ApplyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError())
}
if out.Error == "" {
t.Error("ApplyResource with evidence.checked=false should return non-empty error")
Expand All @@ -51,7 +51,7 @@ func TestApplyResource_DefaultDeny(t *testing.T) {
// 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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
desired := []interfaces.ResourceSpec{{Name: "vpc1", Type: "infra.vpc"}}

Expand All @@ -71,8 +71,8 @@ func TestApplyResource_AuthzDenies(t *testing.T) {
},
}
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 !errors.Is(err, handler.ErrAuthzDenied) {
t.Fatalf("ApplyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError())
}
if out.Error == "" {
t.Error("ApplyResource should reject subject denied infra:apply by server-side Enforcer")
Expand All @@ -82,7 +82,7 @@ func TestApplyResource_AuthzDenies(t *testing.T) {
// TestApplyResource_HappyPath asserts that a valid evidence + hash + allowed
// subject returns applied[] with no errors.
func TestApplyResource_HappyPath(t *testing.T) {
prov := stubprovider.New()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
desired := []interfaces.ResourceSpec{
{Name: "vpc1", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}},
Expand Down Expand Up @@ -114,7 +114,7 @@ func TestApplyResource_HappyPath(t *testing.T) {
// TestApplyResource_StalePlanHash asserts that a mismatched desired_hash
// → "plan is stale" error and no apply.
func TestApplyResource_StalePlanHash(t *testing.T) {
prov := stubprovider.New()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
desired := []interfaces.ResourceSpec{{Name: "vpc1", Type: "infra.vpc"}}

Expand Down
14 changes: 13 additions & 1 deletion iac/admin/handler/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,19 @@ package handler
// Not in T5 scope; flagged here so a future contributor sees the
// risk before extending the handler family.

import adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto"
import (
"errors"

adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto"
)

// ErrAuthzDenied is the sentinel error returned by handlers when an
// authz check (evidence default-deny or server-side RBAC via Enforcer)
// rejects the request. The HTTP module layer maps this via errors.Is to
// HTTP 403 — NOT via strings.Contains on the error message, which would
// produce false positives when a provider's error message happens to
// contain "denied".
var ErrAuthzDenied = errors.New("authz denied")

// authzError returns the operator-facing rejection string when the
// supplied evidence does not meet default-deny criteria. Returns ""
Expand Down
6 changes: 4 additions & 2 deletions iac/admin/handler/destroy_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func DestroyResource(
) (*adminpb.AdminDestroyOutput, error) {
// Gate 1: default-deny.
if msg := authzError(in.GetEvidence()); msg != "" {
return &adminpb.AdminDestroyOutput{Error: msg}, nil
return &adminpb.AdminDestroyOutput{Error: msg}, ErrAuthzDenied
}

// Gate 2: server-side RBAC.
Expand All @@ -43,7 +43,9 @@ func DestroyResource(
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
// Generic denial — do NOT reflect the authenticated subject in the
// response body. Subject is captured by the module-layer audit log.
return &adminpb.AdminDestroyOutput{Error: "destroy: infra:destroy denied"}, ErrAuthzDenied
}
}

Expand Down
18 changes: 9 additions & 9 deletions iac/admin/handler/destroy_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ package handler_test

import (
"context"
"errors"
"testing"

"github.com/GoCodeAlone/workflow/iac/admin/handler"
adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto"
"github.com/GoCodeAlone/workflow/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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
in := &adminpb.AdminDestroyInput{
Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false},
Expand All @@ -22,8 +22,8 @@ func TestDestroyResource_DefaultDeny(t *testing.T) {
},
}
out, err := handler.DestroyResource(context.Background(), providers, nil, "operator", in)
if err != nil {
t.Fatalf("DestroyResource: unexpected Go error: %v", err)
if !errors.Is(err, handler.ErrAuthzDenied) {
t.Fatalf("DestroyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError())
}
if out.Error == "" {
t.Error("DestroyResource with evidence.checked=false should return non-empty error")
Expand All @@ -33,7 +33,7 @@ func TestDestroyResource_DefaultDeny(t *testing.T) {
// 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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
enforcer := &testEnforcer{allow: map[string]bool{
// viewer is NOT granted infra:destroy
Expand All @@ -43,8 +43,8 @@ func TestDestroyResource_AuthzDenies(t *testing.T) {
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 !errors.Is(err, handler.ErrAuthzDenied) {
t.Fatalf("DestroyResource: want ErrAuthzDenied, got %v (out.Error=%s)", err, out.GetError())
}
if out.Error == "" {
t.Error("DestroyResource should reject subject denied infra:destroy by server-side Enforcer")
Expand All @@ -54,7 +54,7 @@ func TestDestroyResource_AuthzDenies(t *testing.T) {
// 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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
refs := []*adminpb.AdminResourceRef{
{Name: "vpc1", Type: "infra.vpc"},
Expand All @@ -80,7 +80,7 @@ func TestDestroyResource_HappyPath(t *testing.T) {
// 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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
refs := []*adminpb.AdminResourceRef{
{Name: "vpc1", Type: "infra.vpc"},
Expand Down
5 changes: 2 additions & 3 deletions iac/admin/handler/drift_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ import (

"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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
in := &adminpb.AdminDriftInput{
Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false},
Expand All @@ -36,7 +35,7 @@ func TestDriftCheckResource_DefaultDeny(t *testing.T) {
// 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()
prov := &planningProvider{}
providers := map[string]interfaces.IaCProvider{"stub": prov}
in := &adminpb.AdminDriftInput{
Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true},
Expand Down
3 changes: 3 additions & 0 deletions iac/admin/handler/get_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ func GetResource(
if msg := authzError(in.GetEvidence()); msg != "" {
return &adminpb.AdminGetResourceOutput{Error: msg}, nil
}
if store == nil {
return &adminpb.AdminGetResourceOutput{Error: "get: no state store configured"}, nil
}

state, err := store.GetResource(ctx, in.GetName())
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions iac/admin/handler/list_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ func ListResources(
if msg := authzError(in.GetEvidence()); msg != "" {
return &adminpb.AdminListResourcesOutput{Error: msg}, nil
}
if store == nil {
return &adminpb.AdminListResourcesOutput{Error: "list: no state store configured"}, nil
}

states, err := store.ListResources(ctx)
if err != nil {
Expand Down
119 changes: 119 additions & 0 deletions iac/admin/handler/list_resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,125 @@ func authzOK() *adminpb.AdminAuthzEvidence {
}
}

// planningProvider is a minimal interfaces.IaCProvider for handler tests.
// It replaces the deleted iac/stubprovider package — scenario fixtures must
// not live in the workflow engine core. Provides real Plan, Destroy,
// DetectDrift, and ResourceDriver behavior so tests can exercise the full
// dispatch path without an external package dependency.
type planningProvider struct{}

var _ interfaces.IaCProvider = (*planningProvider)(nil)

func (p *planningProvider) Name() string { return "test-planning" }
func (p *planningProvider) Version() string { return "0.0.0-test" }
func (p *planningProvider) Initialize(_ context.Context, _ map[string]any) error {
return nil
}
func (p *planningProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil }

func (p *planningProvider) Plan(_ context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (*interfaces.IaCPlan, error) {
currentByName := make(map[string]*interfaces.ResourceState, len(current))
for i := range current {
currentByName[current[i].Name] = &current[i]
}
desiredByName := make(map[string]struct{}, len(desired))
for _, s := range desired {
desiredByName[s.Name] = struct{}{}
}
plan := &interfaces.IaCPlan{}
for _, spec := range desired {
if _, exists := currentByName[spec.Name]; exists {
plan.Actions = append(plan.Actions, interfaces.PlanAction{Action: "update", Resource: spec, Current: currentByName[spec.Name]})
} else {
plan.Actions = append(plan.Actions, interfaces.PlanAction{Action: "create", Resource: spec})
}
}
for i := range current {
st := &current[i]
if _, wanted := desiredByName[st.Name]; !wanted {
plan.Actions = append(plan.Actions, interfaces.PlanAction{Action: "delete", Resource: interfaces.ResourceSpec{Name: st.Name, Type: st.Type}, Current: st})
}
}
return plan, nil
}

func (p *planningProvider) Destroy(_ context.Context, refs []interfaces.ResourceRef) (*interfaces.DestroyResult, error) {
names := make([]string, 0, len(refs))
for _, r := range refs {
names = append(names, r.Name)
}
return &interfaces.DestroyResult{Destroyed: names}, nil
}

func (p *planningProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) {
return nil, nil
}

func (p *planningProvider) DetectDrift(_ context.Context, refs []interfaces.ResourceRef) ([]interfaces.DriftResult, error) {
results := make([]interfaces.DriftResult, 0, len(refs))
for _, r := range refs {
results = append(results, interfaces.DriftResult{Name: r.Name, Type: r.Type, Drifted: false, Class: interfaces.DriftClassInSync})
}
return results, nil
}

func (p *planningProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) {
return nil, nil
}

func (p *planningProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) {
return nil, nil
}

func (p *planningProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) {
return &planningDriver{}, nil
}

func (p *planningProvider) SupportedCanonicalKeys() []string { return nil }

func (p *planningProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) {
return nil, nil
}

func (p *planningProvider) Close() error { return nil }

// planningDriver is a minimal interfaces.ResourceDriver for handler tests.
type planningDriver struct{}

var _ interfaces.ResourceDriver = (*planningDriver)(nil)

func (d *planningDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "test-" + spec.Name}, nil
}

func (d *planningDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) {
return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID}, nil
}

func (d *planningDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
pid := ref.ProviderID
if pid == "" {
pid = "test-" + spec.Name
}
return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: pid}, nil
}

func (d *planningDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil }

func (d *planningDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) {
return &interfaces.DiffResult{NeedsUpdate: false, NeedsReplace: false}, nil
}

func (d *planningDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) {
return nil, nil
}

func (d *planningDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) {
return nil, nil
}

func (d *planningDriver) SensitiveKeys() []string { return nil }

// seedFixture returns a 3-resource store + label-bearing state covering
// the filter dimensions: type (infra.vpc vs infra.database), provider
// module (do-prod vs do-staging), and app_context (web vs api).
Expand Down
8 changes: 5 additions & 3 deletions iac/admin/handler/plan_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import (
// 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).
// Evidence default-deny: if authzError is non-empty, the handler returns
// (output, ErrAuthzDenied). The module layer maps this to HTTP 403 via
// writeMutationResponse — NOT 200; the proto tag-100 pattern applies only
// to non-authz errors (provider/backend failures).
func PlanResource(
ctx context.Context,
store interfaces.IaCStateStore, //nolint:revive // nil ok when no state needed (e.g. fresh deploy)
Expand All @@ -36,7 +38,7 @@ func PlanResource(
in *adminpb.AdminPlanInput,
) (*adminpb.AdminPlanOutput, error) {
if msg := authzError(in.GetEvidence()); msg != "" {
return &adminpb.AdminPlanOutput{Error: msg}, nil
return &adminpb.AdminPlanOutput{Error: msg}, ErrAuthzDenied
}
Comment on lines 40 to 42
if len(providers) == 0 {
return &adminpb.AdminPlanOutput{Error: "plan: no iac.provider registered"}, nil
Expand Down
Loading
Loading