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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ coverage.html
# Config files with secrets
config.yaml
proxy-config.yaml
proxy-config.local.yaml
.env
.envrc

Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ CLI commands and groups include:
- `upgrade`
- `upload`
- `version`
- `workflow` — client for the external workflow engine, relayed server → proxy; the
proxy holds the credential (not a module, no MCP tool or resources)

The proxy is a separate binary, built with `make build-proxy`.

Expand Down Expand Up @@ -250,6 +252,7 @@ pkg/
embedding/ # Remote embedding client for semantic search
config/ # Configuration loading and validation
observability/ # Prometheus metrics
workflowrelay/ # Workflow-passthrough contract shared by the server and proxy hops
types/ # Shared data types
datasets/ # Dataset knowledge packs (content-only module)
modules/
Expand Down
18 changes: 18 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ proxies:
# mode: "oidc" # "oidc", "oauth", or "client_credentials"
# issuer_url: "https://panda-proxy.ethpandaops.io"
# client_id: "panda"
# # Extra scopes requested at login. To use `panda workflow ...` against a
# # proxy running the workflow engine in passthrough mode, request the
# # "workflows" scope: Authentik cross-grants the workflow-engine audience
# # into this token so the same credential is accepted upstream. When set,
# # the list is sent verbatim — keep the base scopes and add "workflows".
# # `panda init` provisions this automatically: an oidc proxy running the
# # workflow engine in passthrough mode returns the scope set via
# # /auth/metadata, so a freshly generated config already includes it. Set
# # it by hand only when writing config manually or overriding what the
# # proxy advertises.
# scopes: ["openid", "email", "groups", "offline_access", "workflows"]
#
# Non-interactive service accounts (e.g. bots) use the client_credentials
# grant with an Authentik service account + app password. Access tokens are
Expand Down Expand Up @@ -83,6 +94,13 @@ proxies:
# repository: "ethereum/consensus-specs" # GitHub owner/repo (for forks)
# ref: "" # Branch, tag, or SHA (empty = latest release)

# Workflow-engine access has NO server-side config. The server relays
# `panda workflow ...` calls to whichever proxy advertises the workflow engine;
# the credential is held only by the proxy. Configure the engine in the
# `workflow:` block of proxy-config.yaml, not here. When that proxy runs the
# engine in passthrough mode, request the "workflows" scope in the proxy
# auth.scopes above so your login token is accepted upstream.

# Observability configuration
observability:
metrics_enabled: true
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ If a change affects product semantics, defaults, validation, output shape, or th
- datasource discovery via `GET /datasources` (authenticated, returns metadata without credentials)
- hosted auth control plane for remote users
- proxy-scoped bearer token validation
- raw upstream relay to ClickHouse, Prometheus, Loki, and Ethereum nodes
- raw upstream relay to ClickHouse, Prometheus, Loki, Ethereum nodes, and the workflow engine
- rate limiting and audit logging

`proxy` must not own user-facing operation semantics.
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/moby/moby/client v0.4.1
github.com/oapi-codegen/runtime v1.3.1
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/redis/go-redis/v9 v9.20.0
github.com/rivo/tview v0.42.0
github.com/sirupsen/logrus v1.9.4
Expand Down Expand Up @@ -83,6 +84,7 @@ require (
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/magiconair/properties v1.8.10 // indirect
Expand Down Expand Up @@ -110,7 +112,6 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
Expand Down
1 change: 1 addition & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ func (a *App) proxyClientConfig(proxyCfg config.ProxyConfig, onDiscover func())
cfg.IssuerURL = proxyCfg.ResolvedAuthIssuerURL()
cfg.ClientID = proxyCfg.Auth.ClientID
cfg.Resource = proxyCfg.ResolvedAuthResource()
cfg.Scopes = proxyCfg.Auth.Scopes
cfg.RefreshTokenTTL = proxyCfg.Auth.RefreshTokenTTL
cfg.AuthMode = strings.TrimSpace(proxyCfg.Auth.Mode)
cfg.Username = strings.TrimSpace(proxyCfg.Auth.Username)
Expand Down
9 changes: 8 additions & 1 deletion pkg/auth/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,17 @@ type DeviceAuth struct {
Interval int
}

// DefaultScopes are the OAuth scopes the client requests when none are
// configured: OIDC identity, the groups claim the proxy authorizes on, and
// offline_access for refresh tokens. It is the single source of truth so that
// callers advertising or provisioning scopes (e.g. proxy /auth/metadata,
// `panda init`) stay in sync with what the client actually requests.
var DefaultScopes = []string{"openid", "email", "groups", "offline_access"}

// New creates a new OAuth client.
func New(log logrus.FieldLogger, cfg Config) Client {
if len(cfg.Scopes) == 0 {
cfg.Scopes = []string{"openid", "email", "groups", "offline_access"}
cfg.Scopes = append([]string(nil), DefaultScopes...)
}

return &client{
Expand Down
3 changes: 3 additions & 0 deletions pkg/auth/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type Config struct {
ClientID string
// Resource is the optional RFC 8707 resource indicator.
Resource string
// Scopes are the OAuth scopes to request; empty means the client's defaults.
Scopes []string
// Username and Password are the service-account credentials for
// ModeClientCredentials.
Username string
Expand Down Expand Up @@ -103,6 +105,7 @@ func NewSource(log logrus.FieldLogger, cfg Config) Source {
IssuerURL: issuer,
ClientID: clientID,
Resource: resource,
Scopes: cfg.Scopes,
Username: cfg.Username,
Password: cfg.Password,
})
Expand Down
19 changes: 17 additions & 2 deletions pkg/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ type initAuthConfig struct {
Mode string
IssuerURL string
ClientID string
Scopes []string
}

// serverImageForVersion returns the published server image reference pinned to
Expand Down Expand Up @@ -282,6 +283,19 @@ func buildConfigTemplate(proxyURL, sandboxImage string, auth initAuthConfig) str
modeField = fmt.Sprintf("\n mode: %q", auth.Mode)
}

// When the proxy advertises scopes (e.g. "workflows" for a configured
// workflow engine), pin them verbatim — an explicit scopes list replaces the
// client defaults, so the proxy advertises the complete set to request.
scopesField := ""
if len(auth.Scopes) > 0 {
quoted := make([]string, len(auth.Scopes))
for i, s := range auth.Scopes {
quoted[i] = fmt.Sprintf("%q", s)
}

scopesField = fmt.Sprintf("\n scopes: [%s]", strings.Join(quoted, ", "))
}

return fmt.Sprintf(`# panda configuration
# Generated by 'panda init'. See https://github.com/ethpandaops/panda for all options.

Expand All @@ -304,8 +318,8 @@ proxy:
url: %q
auth:%s
issuer_url: %q
client_id: %q
`, sandboxImage, proxyURL, modeField, auth.IssuerURL, auth.ClientID)
client_id: %q%s
`, sandboxImage, proxyURL, modeField, auth.IssuerURL, auth.ClientID, scopesField)
}

// discoverProxyAuth fetches auth metadata from the proxy's /auth/metadata
Expand Down Expand Up @@ -348,6 +362,7 @@ func discoverProxyAuth(ctx context.Context, proxyURL string) initAuthConfig {
Mode: meta.Mode,
IssuerURL: meta.IssuerURL,
ClientID: meta.ClientID,
Scopes: meta.Scopes,
}
}

Expand Down
45 changes: 45 additions & 0 deletions pkg/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,51 @@ func TestBuildConfigTemplate(t *testing.T) {
}
}

func TestBuildConfigTemplateScopes(t *testing.T) {
t.Parallel()

t.Run("advertised scopes are pinned verbatim", func(t *testing.T) {
t.Parallel()

authCfg := initAuthConfig{
IssuerURL: "https://authentik.example.com/application/o/panda-proxy/",
ClientID: "panda-proxy",
Scopes: []string{"openid", "email", "groups", "offline_access", "workflows"},
}
result := buildConfigTemplate(defaultProxyURL, defaultSandboxImage, authCfg)

var parsed map[string]any
require.NoError(t, yaml.Unmarshal([]byte(result), &parsed), "generated config must be valid YAML")

auth := parsed["proxy"].(map[string]any)["auth"].(map[string]any)
scopes, ok := auth["scopes"].([]any)
require.True(t, ok, "auth block must carry the advertised scopes")

got := make([]string, len(scopes))
for i, s := range scopes {
got[i] = s.(string)
}
assert.Equal(t, authCfg.Scopes, got)
})

t.Run("no scopes key when the proxy advertises none", func(t *testing.T) {
t.Parallel()

authCfg := initAuthConfig{
IssuerURL: "https://dex.example.com",
ClientID: "panda-proxy",
}
result := buildConfigTemplate(defaultProxyURL, defaultSandboxImage, authCfg)

var parsed map[string]any
require.NoError(t, yaml.Unmarshal([]byte(result), &parsed), "generated config must be valid YAML")

auth := parsed["proxy"].(map[string]any)["auth"].(map[string]any)
_, hasScopes := auth["scopes"]
assert.False(t, hasScopes, "omitting scopes lets the client defaults apply")
})
}

func TestBuildComposeTemplate(t *testing.T) {
t.Parallel()

Expand Down
5 changes: 4 additions & 1 deletion pkg/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ tables, columns, or query syntax:

Most topic words are search terms, not subcommands. Full guide:

panda getting-started`
panda getting-started

Not a data question? Drive the workflow engine with ` + "`panda workflow`" + ` (authoring
and running multi-step agent workflows), not Ethereum data queries.`

// updateResult carries the latest version from the background check.
// A nil value means the check failed or was skipped.
Expand Down
36 changes: 25 additions & 11 deletions pkg/cli/serverclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ type rawServerResponse struct {
ContentType string
}

// applyEnvAttribution stamps the caller-attribution (on-behalf-of) header from
// the environment onto an outbound server request, when set. Every server
// request — buffered (serverDo) and streaming (workflowStream) alike — must go
// through this so the proxy's audit log attributes all of them.
func applyEnvAttribution(h http.Header) {
if v := strings.TrimSpace(os.Getenv(attribution.EnvVar)); v != "" {
h.Set(attribution.Header, v)
}
}

func serverBaseURL() (string, error) {
cfg, err := config.LoadClient(cfgFile)
if err != nil {
Expand Down Expand Up @@ -63,9 +73,7 @@ func serverDo(
req.Header.Set(key, value)
}

if v := strings.TrimSpace(os.Getenv(attribution.EnvVar)); v != "" {
req.Header.Set(attribution.Header, v)
}
applyEnvAttribution(req.Header)

resp, err := serverHTTP.Do(req)
if err != nil {
Expand Down Expand Up @@ -216,20 +224,26 @@ func (e *apiError) Error() string {
}

func decodeAPIError(status int, data []byte) error {
var message string
return &apiError{Status: status, Message: apiErrorMessage(data)}
}

// apiErrorMessage extracts a human-readable message from an error response body.
// It tries the common JSON error fields in order — panda's `error`, then RFC
// 7807 problem+json `detail` and `title` — before falling back to the raw body.
// The raw-body fallback covers non-JSON responses (e.g. an unknown route's
// `text/plain` "404 page not found"), so decoding never panics or loses the
// message.
func apiErrorMessage(data []byte) string {
var payload map[string]any
if err := json.Unmarshal(data, &payload); err == nil {
if msg, ok := payload["error"].(string); ok && msg != "" {
message = msg
for _, key := range []string{"error", "detail", "title"} {
if msg, ok := payload[key].(string); ok && msg != "" {
return msg
}
}
}

if message == "" {
message = strings.TrimSpace(string(data))
}

return &apiError{Status: status, Message: message}
return strings.TrimSpace(string(data))
}

func serverErrorHint(status int, message string) string {
Expand Down
Loading
Loading