Skip to content

Commit 635132b

Browse files
committed
feat(dashboard): wire CSRF, idempotency, Prometheus metrics, OTel tracing, structured audit
Replace the safe defaults wired in NewExtension with production-grade contract plumbing in Register(): Prometheus metrics emission, OTel tracing per dispatch, in-memory idempotency dedup for commands, and a structured forge.Logger-backed AuditEmitter. Gated by a new EnableContractSecurity flag (default true) so deployments mid-rollout can opt out without losing the rest of the contract path. - Adds Config.EnableContractSecurity and matching WithContractSecurity option. - handleContractPOST now uses transport.NewHandlerWithCSRF, passing csrfMgr only when the flag is on so commands fail-closed with UNAUTHENTICATED on bad tokens. - Registers GET /api/dashboard/v1/csrf alongside the contract envelope endpoint so the shell can fetch a 12h-validity token; route is guarded on csrfMgr + EnableContractSecurity. - Adds idempotencyAdapter to bridge idempotency.Store -> dispatcher.IdempotencyStore. The two interfaces are kept separate to avoid an import cycle (dispatcher defines its own IdempotencyCached); the conversion is lossless. - Rebuilds streamBroker in Register so SSE subscriptions resolve against the upgraded dispatcher rather than the noop one created in NewExtension. All dashboard tests still green (legacy + contract subtree).
1 parent ede482f commit 635132b

2 files changed

Lines changed: 107 additions & 3 deletions

File tree

extensions/dashboard/config.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ type Config struct {
5353
// Security
5454
EnableCSP bool `json:"enable_csp" yaml:"enable_csp"`
5555
EnableCSRF bool `json:"enable_csrf" yaml:"enable_csrf"`
56+
// EnableContractSecurity gates CSRF validation, idempotency dedup, and
57+
// distributed tracing on the contract envelope endpoint. Default true;
58+
// set to false during a rollout window where clients have not yet
59+
// adopted CSRF tokens or the idempotency-key contract.
60+
EnableContractSecurity bool `json:"enable_contract_security" yaml:"enable_contract_security"`
5661

5762
// Authentication
5863
EnableAuth bool `json:"enable_auth" yaml:"enable_auth"` // enable auth support
@@ -101,8 +106,9 @@ func DefaultConfig() Config {
101106

102107
SSEKeepAlive: 15 * time.Second,
103108

104-
EnableCSP: true,
105-
EnableCSRF: true,
109+
EnableCSP: true,
110+
EnableCSRF: true,
111+
EnableContractSecurity: true,
106112

107113
EnableAuth: false,
108114
LoginPath: "/login",
@@ -255,6 +261,14 @@ func WithCSRF(enabled bool) ConfigOption {
255261
return func(c *Config) { c.EnableCSRF = enabled }
256262
}
257263

264+
// WithContractSecurity enables or disables the contract envelope's
265+
// security stack (CSRF validation, idempotency dedup, request tracing).
266+
// Defaults to true; switching off should be reserved for rollout windows
267+
// where clients have not yet adopted CSRF tokens or idempotency keys.
268+
func WithContractSecurity(enabled bool) ConfigOption {
269+
return func(c *Config) { c.EnableContractSecurity = enabled }
270+
}
271+
258272
// WithTheme sets the UI theme (light, dark, auto).
259273
func WithTheme(theme string) ConfigOption {
260274
return func(c *Config) { c.Theme = theme }

extensions/dashboard/extension.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/a-h/templ"
1414
"github.com/xraph/forge"
1515
"github.com/xraph/vessel"
16+
"go.opentelemetry.io/otel"
1617

1718
internalmetrics "github.com/xraph/forge/internal/metrics"
1819

@@ -21,6 +22,7 @@ import (
2122
"github.com/xraph/forge/extensions/dashboard/collector"
2223
"github.com/xraph/forge/extensions/dashboard/contract"
2324
"github.com/xraph/forge/extensions/dashboard/contract/dispatcher"
25+
"github.com/xraph/forge/extensions/dashboard/contract/idempotency"
2426
"github.com/xraph/forge/extensions/dashboard/contract/loader"
2527
"github.com/xraph/forge/extensions/dashboard/contract/pilot"
2628
"github.com/xraph/forge/extensions/dashboard/contract/transport"
@@ -273,6 +275,37 @@ func (e *Extension) Register(app forge.App) error {
273275
e.searcher.RebuildIndex()
274276
}
275277

278+
// Slice (b) Phase 6: replace the safe defaults wired in NewExtension with
279+
// production-grade contract plumbing — Prometheus metrics emission, OTel
280+
// tracing, idempotency dedup, and structured audit logging. The swap is
281+
// gated by EnableContractSecurity so deployments mid-rollout (clients not
282+
// yet sending CSRF tokens / idempotency keys) can opt out without losing
283+
// the rest of the contract path. Must run before pilot.Register so the
284+
// pilot binds against the upgraded dispatcher.
285+
var metricsEmitter dispatcher.MetricsEmitter = dispatcher.NoopMetricsEmitter{}
286+
if app != nil && app.Metrics() != nil {
287+
metricsEmitter = dispatcher.NewPrometheusMetricsEmitter(app.Metrics())
288+
}
289+
var dispOpts []dispatcher.Option
290+
if e.config.EnableContractSecurity {
291+
dispOpts = append(dispOpts,
292+
dispatcher.WithTracer(otel.Tracer("forge.dashboard.contract")),
293+
dispatcher.WithIdempotencyStore(adaptIdempotencyStore(idempotency.NewInMemoryStore())),
294+
)
295+
}
296+
e.dispatcher = dispatcher.NewWithOptions(metricsEmitter, dispOpts...)
297+
298+
var auditEmitter contract.AuditEmitter = contract.NewLogAuditEmitter(os.Stdout)
299+
if app != nil && app.Logger() != nil {
300+
auditEmitter = dispatcher.NewLoggerAuditEmitter(app.Logger())
301+
}
302+
e.auditEmitter = auditEmitter
303+
304+
// The streamBroker captured the old dispatcher at NewExtension time;
305+
// rebind it to the upgraded dispatcher so SSE subscriptions resolve
306+
// against the same handler registry the POST endpoint uses.
307+
e.streamBroker = transport.NewStreamBroker(e.contractRegistry, e.wardenRegistry, e.dispatcher)
308+
276309
// Register the contract-track pilot contributor (core-contract). This
277310
// loads the embedded manifest, validates it against the warden registry,
278311
// and binds the four pilot handlers (extensions.list / services.list /
@@ -1334,6 +1367,13 @@ func (e *Extension) registerRoutes() {
13341367
must(router.GET(base+"/api/dashboard/v1/stream", http.HandlerFunc(e.streamBroker.ServeStream)))
13351368
must(router.POST(base+"/api/dashboard/v1/stream/control", http.HandlerFunc(e.streamBroker.ServeControl)))
13361369
}
1370+
// Slice (b) Phase 6: surface CSRF tokens to the shell only when the
1371+
// security stack is wired (csrfMgr is non-nil iff EnableCSRF is true,
1372+
// and EnableContractSecurity gates the contract path's enforcement).
1373+
if e.csrfMgr != nil && e.config.EnableContractSecurity {
1374+
must(router.GET(base+"/api/dashboard/v1/csrf",
1375+
transport.NewCSRFTokenHandler(e.csrfMgr, 12*time.Hour).ServeHTTP))
1376+
}
13371377
}
13381378

13391379
// 4. Export endpoints (stay on forge.Router)
@@ -1419,8 +1459,17 @@ func (e *Extension) registerRoutes() {
14191459
// replaces slice (a)'s safe NilDispatcher with the real dispatcher wired in
14201460
// NewExtension; intent handlers are bound by pilot.Register during
14211461
// Extension.Register so requests resolve to live data instead of CodeUnavailable.
1462+
//
1463+
// Slice (b) Phase 6 routes CSRF validation through the handler when the
1464+
// extension's CSRF manager is configured AND EnableContractSecurity is on.
1465+
// Passing nil to NewHandlerWithCSRF preserves the slice-(a) behaviour — useful
1466+
// during a rollout window where clients have not yet adopted CSRF tokens.
14221467
func (e *Extension) handleContractPOST() http.HandlerFunc {
1423-
h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter)
1468+
var mgr *security.CSRFManager
1469+
if e.config.EnableContractSecurity && e.csrfMgr != nil {
1470+
mgr = e.csrfMgr
1471+
}
1472+
h := transport.NewHandlerWithCSRF(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter, mgr)
14241473
return h.ServeHTTP
14251474
}
14261475

@@ -1558,3 +1607,44 @@ func (e *Extension) registerAuthPages() {
15581607
forge.F("count", len(pages)),
15591608
)
15601609
}
1610+
1611+
// idempotencyAdapter bridges idempotency.Store (the production interface) to
1612+
// dispatcher.IdempotencyStore (the dispatcher-private surface). The two types
1613+
// are intentionally separate: the dispatcher defines its own minimal
1614+
// IdempotencyStore + IdempotencyCached pair to avoid an import cycle with
1615+
// the contract/idempotency sub-package, which itself imports nothing from
1616+
// dispatcher. The conversion is lossless.
1617+
type idempotencyAdapter struct{ inner idempotency.Store }
1618+
1619+
// adaptIdempotencyStore returns a dispatcher.IdempotencyStore backed by an
1620+
// idempotency.Store. Used at NewExtension/Register time to wire the in-memory
1621+
// store into the dispatcher.
1622+
func adaptIdempotencyStore(s idempotency.Store) dispatcher.IdempotencyStore {
1623+
return &idempotencyAdapter{inner: s}
1624+
}
1625+
1626+
// Lookup forwards to the underlying store, converting the cached envelope
1627+
// shape between the two types.
1628+
func (a *idempotencyAdapter) Lookup(ctx context.Context, key, identity string) (*dispatcher.IdempotencyCached, bool) {
1629+
c, ok := a.inner.Lookup(ctx, key, identity)
1630+
if !ok {
1631+
return nil, false
1632+
}
1633+
return &dispatcher.IdempotencyCached{
1634+
Status: c.Status,
1635+
WireBody: c.WireBody,
1636+
StoredAt: c.StoredAt,
1637+
TTL: c.TTL,
1638+
}, true
1639+
}
1640+
1641+
// Store forwards to the underlying store, converting the cached envelope
1642+
// shape between the two types.
1643+
func (a *idempotencyAdapter) Store(ctx context.Context, key, identity string, c dispatcher.IdempotencyCached) error {
1644+
return a.inner.Store(ctx, key, identity, idempotency.Cached{
1645+
Status: c.Status,
1646+
WireBody: c.WireBody,
1647+
StoredAt: c.StoredAt,
1648+
TTL: c.TTL,
1649+
})
1650+
}

0 commit comments

Comments
 (0)