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
10 changes: 9 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,15 @@ func (app *serverApp) initStores(logger *slog.Logger) error {
// -----------------------------------------------------------------------

billingMeter := billing.NewInMemoryMeter()
billingProvider := billing.NewMockBillingProvider()
var billingProvider billing.BillingProvider
if stripeKey := os.Getenv("STRIPE_API_KEY"); stripeKey != "" {
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
billingProvider = billing.NewStripeProvider(stripeKey, webhookSecret, billing.StripePlanIDs{})
logger.Info("Billing: using Stripe provider")
Comment on lines +718 to +719
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STRIPE_API_KEY now enables the Stripe billing provider, but it’s constructed with an empty planPriceIDs map (billing.StripePlanIDs{}). This guarantees CreateSubscription will fail at runtime with "no stripe price ID configured" for every plan, breaking the /subscribe endpoint. Consider loading plan→price IDs from config/env (or refusing to enable Stripe until they’re configured and falling back to mock with a clear warning/error).

Suggested change
billingProvider = billing.NewStripeProvider(stripeKey, webhookSecret, billing.StripePlanIDs{})
logger.Info("Billing: using Stripe provider")
// Load Stripe plan price IDs from environment. Expect a JSON object mapping plan names to Stripe price IDs,
// e.g. {"basic":"price_123","pro":"price_456"}.
var stripePlanIDs billing.StripePlanIDs
if rawPlanIDs := os.Getenv("STRIPE_PLAN_PRICE_IDS"); rawPlanIDs != "" {
if err := json.Unmarshal([]byte(rawPlanIDs), &stripePlanIDs); err != nil {
logger.Error("Billing: invalid STRIPE_PLAN_PRICE_IDS; falling back to MockBillingProvider", "error", err)
}
}
if len(stripePlanIDs) == 0 {
logger.Warn("Billing: STRIPE_API_KEY is set but no Stripe plan price IDs configured (STRIPE_PLAN_PRICE_IDS); using MockBillingProvider instead of Stripe to avoid runtime failures")
billingProvider = billing.NewMockBillingProvider()
} else {
billingProvider = billing.NewStripeProvider(stripeKey, webhookSecret, stripePlanIDs)
logger.Info("Billing: using Stripe provider")
}

Copilot uses AI. Check for mistakes.
} else {
logger.Warn("STRIPE_API_KEY not set — billing is using MockBillingProvider; set STRIPE_API_KEY to enable real billing")
billingProvider = billing.NewMockBillingProvider()
}
billingHandler := billing.NewHandler(billingMeter, billingProvider)
billingMux := http.NewServeMux()
billingHandler.RegisterRoutes(billingMux)
Expand Down
5 changes: 3 additions & 2 deletions cmd/wfctl/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func runContract(args []string) error {
return contractUsage()
}
switch args[0] {
case "test":
case "test", "compare":
return runContractTest(args[1:])
default:
return contractUsage()
Expand All @@ -104,7 +104,8 @@ func contractUsage() error {
fmt.Fprintf(os.Stderr, `Usage: wfctl contract <subcommand> [options]

Subcommands:
test Generate a contract from a config and optionally compare to a baseline
test Generate a contract from a config and optionally compare to a baseline
compare Alias for test

Run 'wfctl contract test -h' for details.
`)
Expand Down
75 changes: 40 additions & 35 deletions cmd/wfctl/git_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"os/exec"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

// wfctlConfig represents the .wfctl.yaml project config file.
Expand Down Expand Up @@ -236,51 +238,54 @@ ui/node_modules/
return os.WriteFile(".gitignore", []byte(content), 0600)
}

// wfctlConfigFile mirrors the nested YAML structure of .wfctl.yaml for unmarshaling.
type wfctlConfigFile struct {
Project struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
ConfigFile string `yaml:"configFile"`
} `yaml:"project"`
Git struct {
Repository string `yaml:"repository"`
Branch string `yaml:"branch"`
AutoPush bool `yaml:"autoPush"`
GenerateActions bool `yaml:"generateActions"`
} `yaml:"git"`
Deploy struct {
Target string `yaml:"target"`
Namespace string `yaml:"namespace"`
} `yaml:"deploy"`
}

// loadWfctlConfig reads .wfctl.yaml from the current directory.
func loadWfctlConfig() (*wfctlConfig, error) {
data, err := os.ReadFile(".wfctl.yaml")
if err != nil {
return nil, fmt.Errorf("failed to read .wfctl.yaml: %w (run 'wfctl git connect' first)", err)
}

var raw wfctlConfigFile
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse .wfctl.yaml: %w", err)
}

cfg := &wfctlConfig{
GitBranch: "main",
DeployTarget: "kubernetes",
ProjectName: raw.Project.Name,
ProjectVersion: raw.Project.Version,
ConfigFile: raw.Project.ConfigFile,
GitRepository: raw.Git.Repository,
GitBranch: raw.Git.Branch,
GitAutoPush: raw.Git.AutoPush,
GenerateActions: raw.Git.GenerateActions,
DeployTarget: raw.Deploy.Target,
DeployNamespace: raw.Deploy.Namespace,
}

// Simple line-by-line YAML parser for .wfctl.yaml
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
val := strings.TrimSpace(kv[1])

switch key {
case "name":
cfg.ProjectName = val
case "version":
cfg.ProjectVersion = strings.Trim(val, "\"")
case "configFile":
cfg.ConfigFile = val
case "repository":
cfg.GitRepository = val
case "branch":
cfg.GitBranch = val
case "autoPush":
cfg.GitAutoPush = val == "true"
case "generateActions":
cfg.GenerateActions = val == "true"
case "target":
cfg.DeployTarget = val
case "namespace":
cfg.DeployNamespace = val
}
if cfg.GitBranch == "" {
cfg.GitBranch = "main"
}
if cfg.DeployTarget == "" {
cfg.DeployTarget = "kubernetes"
}

return cfg, nil
Expand Down
22 changes: 2 additions & 20 deletions cmd/wfctl/plugin_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
Expand Down Expand Up @@ -249,7 +250,7 @@ func verifyChecksum(data []byte, expected string) error {
// extractTarGz decompresses and extracts a .tar.gz archive into destDir.
// It guards against path traversal (zip-slip) attacks.
func extractTarGz(data []byte, destDir string) error {
gzr, err := gzip.NewReader(bytesReader(data))
gzr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("open gzip: %w", err)
}
Expand Down Expand Up @@ -301,25 +302,6 @@ func extractTarGz(data []byte, destDir string) error {
return nil
}

// bytesReader wraps a byte slice as an io.Reader.
type bytesReaderImpl struct {
data []byte
pos int
}

func (b *bytesReaderImpl) Read(p []byte) (int, error) {
if b.pos >= len(b.data) {
return 0, io.EOF
}
n := copy(p, b.data[b.pos:])
b.pos += n
return n, nil
}

func bytesReader(data []byte) io.Reader {
return &bytesReaderImpl{data: data}
}

// stripTopDir removes the first path component from a tar entry name.
// e.g. "workflow-plugin-admin-darwin-amd64/admin.plugin" -> "admin.plugin"
func stripTopDir(name string) string {
Expand Down
6 changes: 6 additions & 0 deletions cmd/wfctl/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ import (
func runRun(args []string) error {
fs := flag.NewFlagSet("run", flag.ExitOnError)
logLevel := fs.String("log-level", "info", "Log level (debug, info, warn, error)")
env := fs.String("env", "", "Environment name (sets WORKFLOW_ENV)")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: wfctl run [options] <config.yaml>\n\nRun a workflow engine from a config file.\n\nOptions:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
if *env != "" {
if err := os.Setenv("WORKFLOW_ENV", *env); err != nil {
return fmt.Errorf("failed to set WORKFLOW_ENV: %w", err)
}
}
if fs.NArg() < 1 {
fs.Usage()
return fmt.Errorf("config file path is required")
Expand Down
64 changes: 49 additions & 15 deletions module/policy_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package module
import (
"context"
"fmt"
"log/slog"
"sync"

"github.com/CrisisTextLine/modular"
Expand Down Expand Up @@ -53,14 +54,20 @@ func (m *PolicyEngineModule) Init(app modular.Application) error {
m.backend = "mock"
}

allowStub := isTruthy(m.config["allow_stub_backends"])

switch m.backend {
case "mock":
m.engine = newMockPolicyEngine()
case "opa":
endpoint, _ := m.config["endpoint"].(string)
m.engine = newOPAPolicyEngine(endpoint)
m.engine = newOPAPolicyEngine(endpoint, allowStub)
slog.Warn("WARNING: using stub policy engine — all requests will be DENIED. Set allow_stub_backends: true in config to use stub backends for testing.",
"module", m.name, "backend", "opa", "allow_stub_backends", allowStub)
Comment on lines +57 to +66
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new config knob (allow_stub_backends) is introduced/advertised in the warning, but this module’s config schema (via the policy plugin) doesn’t appear to expose or document it. That makes it hard for users to discover/configure and increases the chance of accidental misconfiguration. Consider documenting this option alongside other policy module config fields (or renaming it to match an existing convention) and ensuring it’s represented wherever module config is surfaced.

Copilot uses AI. Check for mistakes.
case "cedar":
m.engine = newCedarPolicyEngine()
m.engine = newCedarPolicyEngine(allowStub)
slog.Warn("WARNING: using stub policy engine — all requests will be DENIED. Set allow_stub_backends: true in config to use stub backends for testing.",
"module", m.name, "backend", "cedar", "allow_stub_backends", allowStub)
Comment on lines +65 to +70
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning text says "all requests will be DENIED", but when allow_stub_backends is true the OPA/Cedar stubs return Allowed=true. This makes the log message misleading (and potentially dangerous if someone enables allow_stub_backends and relies on the warning). Consider making the warning conditional and explicitly stating the effective behavior (deny-all vs allow-all) based on allow_stub_backends.

Copilot uses AI. Check for mistakes.
default:
return fmt.Errorf("policy.engine %q: unsupported backend %q", m.name, m.backend)
}
Expand Down Expand Up @@ -165,6 +172,17 @@ func (e *mockPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*P
}, nil
}

// isTruthy returns true if v is a bool true, or a string "true"/"1"/"yes".
func isTruthy(v any) bool {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true" || val == "1" || val == "yes"
}
return false
}

func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
func() bool {
Expand All @@ -182,16 +200,17 @@ func containsString(s, substr string) bool {
// opaPolicyEngine is a stub for OPA (Open Policy Agent) integration.
// Production: POST to the OPA REST API at <endpoint>/v1/data/<policy-path>.
type opaPolicyEngine struct {
endpoint string
mu sync.RWMutex
policies map[string]string
endpoint string
allowStub bool
mu sync.RWMutex
policies map[string]string
}

func newOPAPolicyEngine(endpoint string) *opaPolicyEngine {
func newOPAPolicyEngine(endpoint string, allowStub bool) *opaPolicyEngine {
if endpoint == "" {
endpoint = "http://localhost:8181"
}
return &opaPolicyEngine{endpoint: endpoint, policies: make(map[string]string)}
return &opaPolicyEngine{endpoint: endpoint, allowStub: allowStub, policies: make(map[string]string)}
}

func (e *opaPolicyEngine) LoadPolicy(name, content string) error {
Expand All @@ -215,9 +234,16 @@ func (e *opaPolicyEngine) ListPolicies() []PolicyInfo {
func (e *opaPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*PolicyDecision, error) {
// Production: POST {"input": input} to <endpoint>/v1/data/<default-policy>
// and parse the result body for {"result": {"allow": true}}.
if e.allowStub {
return &PolicyDecision{
Allowed: true,
Reasons: []string{"opa stub: allow_stub_backends enabled"},
Metadata: map[string]any{"backend": "opa", "endpoint": e.endpoint, "input": input},
}, nil
}
return &PolicyDecision{
Allowed: true,
Reasons: []string{"opa stub: default allow"},
Allowed: false,
Reasons: []string{"STUB IMPLEMENTATION - not connected to real backend - denied for safety"},
Metadata: map[string]any{"backend": "opa", "endpoint": e.endpoint, "input": input},
}, nil
Comment on lines 234 to 248
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated OPA/Cedar stub behavior (deny-by-default, allow when allow_stub_backends is enabled) is security-sensitive but currently has no direct unit coverage. Add tests that assert Evaluate() denies when allow_stub_backends is false and allows when it is true for both OPA and Cedar engines, so future refactors don’t accidentally revert to allow-by-default.

Copilot generated this review using guidance from organization custom instructions.
}
Expand All @@ -227,12 +253,13 @@ func (e *opaPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*Po
// cedarPolicyEngine is a stub for Cedar policy language integration.
// Production: use the cedar-go library (github.com/cedar-policy/cedar-go).
type cedarPolicyEngine struct {
mu sync.RWMutex
policies map[string]string
allowStub bool
mu sync.RWMutex
policies map[string]string
}

func newCedarPolicyEngine() *cedarPolicyEngine {
return &cedarPolicyEngine{policies: make(map[string]string)}
func newCedarPolicyEngine(allowStub bool) *cedarPolicyEngine {
return &cedarPolicyEngine{allowStub: allowStub, policies: make(map[string]string)}
}

func (e *cedarPolicyEngine) LoadPolicy(name, content string) error {
Expand All @@ -256,9 +283,16 @@ func (e *cedarPolicyEngine) ListPolicies() []PolicyInfo {
func (e *cedarPolicyEngine) Evaluate(_ context.Context, input map[string]any) (*PolicyDecision, error) {
// Production: build a cedar.Request from input (principal, action, resource, context)
// and call policySet.IsAuthorized(request).
if e.allowStub {
return &PolicyDecision{
Allowed: true,
Reasons: []string{"cedar stub: allow_stub_backends enabled"},
Metadata: map[string]any{"backend": "cedar", "input": input},
}, nil
}
return &PolicyDecision{
Allowed: true,
Reasons: []string{"cedar stub: default allow"},
Allowed: false,
Reasons: []string{"STUB IMPLEMENTATION - not connected to real backend - denied for safety"},
Metadata: map[string]any{"backend": "cedar", "input": input},
}, nil
}
Loading