Skip to content

Commit 3b1a2ad

Browse files
Marko Petzoldclaude
andcommitted
spec/runner: extend with multi-peer, from_any, auth-aware router, compute steps
Builds on the runner skeleton with the features needed for the larger plan set: - Multi-peer plans: declare named peers via top-level `peers:`, dispatch each step to one of them via per-step `peer:`. Single-peer plans omit both and use an implicit "default" peer (zero-config). - {{$name}} placeholder substitution on the send side, so a step can echo router-assigned ids (e.g. INVOCATION id captured from a prior expect) into outgoing messages. - `from_any: [peerA, peerB]` step type with `capture_source` — race Recv on multiple peers via reflect.Select, capture which one delivered. Unblocks plans where the spec leaves dispatch order unspecified (e.g. shared registration with invoke=random). - `peer: "{{$captured_name}}"` — placeholder substitution in the peer field, so a follow-up step targets whichever peer was selected. - Auth-aware router setup via plan-declared `auth:` block. Runner builds a static keystore over in-line user maps and wires Anonymous / Ticket / WAMP-CRA authenticators into the per-test realm config. Default unchanged when `auth:` is absent. - Compute step type with wampcra_sign helper — runs HMAC-SHA256 over a captured challenge and stores the base64 signature back into captures so a subsequent AUTHENTICATE step can use it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 87d3809 commit 3b1a2ad

4 files changed

Lines changed: 517 additions & 22 deletions

File tree

spec/runner/auth.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package runner
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"time"
7+
8+
"github.com/gammazero/nexus/v3/router/auth"
9+
"github.com/gammazero/nexus/v3/wamp"
10+
"github.com/gammazero/nexus/v3/wamp/crsign"
11+
)
12+
13+
// ComputeSpec is a non-wire side-step that derives a value from
14+
// captures and stores it back into captures. Currently only
15+
// `wampcra_sign` is implemented; future helpers (e.g. cryptosign,
16+
// hex/base64 transforms) can extend this struct.
17+
type ComputeSpec struct {
18+
WAMPCRASign *WAMPCRASignSpec `yaml:"wampcra_sign,omitempty"`
19+
}
20+
21+
// WAMPCRASignSpec computes an HMAC-SHA256 signature over a previously
22+
// captured challenge string, using the given secret. The result
23+
// (base64-encoded) lands in captures under `Capture` so a later
24+
// `send: [5, "{{$signature}}", {}]` AUTHENTICATE step can use it.
25+
//
26+
// Example:
27+
//
28+
// - expect: [4, "wampcra", {challenge: "{{$challenge:string}}"}]
29+
// - compute:
30+
// wampcra_sign:
31+
// challenge: "{{$challenge}}"
32+
// secret: "alice-secret"
33+
// capture: signature
34+
// - send: [5, "{{$signature}}", {}]
35+
type WAMPCRASignSpec struct {
36+
// Challenge is the challenge string emitted by the router in
37+
// CHALLENGE.Extra.challenge. Use a placeholder like "{{$challenge}}"
38+
// to pull a previously captured value.
39+
Challenge string `yaml:"challenge"`
40+
// Secret is the user's WAMP-CRA secret as bytes (utf-8 string).
41+
Secret string `yaml:"secret"`
42+
// Capture is the placeholder name to store the computed signature
43+
// under. The signature is the base64 HMAC-SHA256 string.
44+
Capture string `yaml:"capture"`
45+
}
46+
47+
// runCompute executes a Compute step, resolving placeholders in its
48+
// inputs against captures and writing the output back.
49+
func runCompute(spec *ComputeSpec, captures map[string]any) error {
50+
if spec == nil {
51+
return errors.New("nil compute spec")
52+
}
53+
if spec.WAMPCRASign != nil {
54+
s := spec.WAMPCRASign
55+
if s.Capture == "" {
56+
return errors.New("wampcra_sign requires `capture` name")
57+
}
58+
challenge, err := resolveStringPlaceholder(s.Challenge, captures)
59+
if err != nil {
60+
return fmt.Errorf("wampcra_sign challenge: %w", err)
61+
}
62+
captures[s.Capture] = crsign.SignChallenge(challenge, []byte(s.Secret))
63+
return nil
64+
}
65+
return errors.New("compute step has no operation set")
66+
}
67+
68+
// resolveStringPlaceholder yields s as-is, OR if s is a "{{$name}}"
69+
// placeholder, the captured value (must be a string).
70+
func resolveStringPlaceholder(s string, captures map[string]any) (string, error) {
71+
if !isPlaceholder(s) {
72+
return s, nil
73+
}
74+
body := s[3 : len(s)-2] // strip {{$ and }}
75+
name := body
76+
if i := indexByte(body, ':'); i != -1 {
77+
name = body[:i]
78+
}
79+
val, ok := captures[name]
80+
if !ok {
81+
return "", fmt.Errorf("placeholder %q references uncaptured name", s)
82+
}
83+
str, ok := val.(string)
84+
if !ok {
85+
return "", fmt.Errorf("placeholder %q resolved to non-string %T", s, val)
86+
}
87+
return str, nil
88+
}
89+
90+
func indexByte(s string, c byte) int {
91+
for i := 0; i < len(s); i++ {
92+
if s[i] == c {
93+
return i
94+
}
95+
}
96+
return -1
97+
}
98+
99+
// AuthSpec is the on-disk YAML auth declaration for a plan. All fields
100+
// are optional. If the whole block is absent (Auth==nil), the runner
101+
// uses the legacy default (anonymous-only, role="anonymous").
102+
//
103+
// Example:
104+
//
105+
// auth:
106+
// anonymous: { role: anonymous }
107+
// ticket:
108+
// users:
109+
// tester: { ticket: "ticket-X-1234", role: "user" }
110+
// wampcra:
111+
// users:
112+
// jdoe: { secret: "squeemishosafradge", role: "user" }
113+
type AuthSpec struct {
114+
Anonymous *AnonymousSpec `yaml:"anonymous,omitempty"`
115+
Ticket *TicketSpec `yaml:"ticket,omitempty"`
116+
WAMPCRA *WAMPCRASpec `yaml:"wampcra,omitempty"`
117+
}
118+
119+
// AnonymousSpec configures anonymous auth. Empty role defaults to "anonymous".
120+
type AnonymousSpec struct {
121+
Role string `yaml:"role,omitempty"`
122+
}
123+
124+
// TicketSpec configures ticket-based dynamic CR auth with a static
125+
// authid → ticket+role map (see test/spec_auth_ticket_test.go for the
126+
// runtime semantics).
127+
type TicketSpec struct {
128+
Users map[string]TicketUser `yaml:"users"`
129+
}
130+
131+
type TicketUser struct {
132+
Ticket string `yaml:"ticket"`
133+
Role string `yaml:"role"`
134+
}
135+
136+
// WAMPCRASpec configures WAMP-CRA auth with a static authid → secret+role map.
137+
type WAMPCRASpec struct {
138+
Users map[string]WAMPCRAUser `yaml:"users"`
139+
}
140+
141+
type WAMPCRAUser struct {
142+
Secret string `yaml:"secret"`
143+
Role string `yaml:"role"`
144+
}
145+
146+
// staticKeyStore implements auth.KeyStore over an in-memory user map
147+
// loaded from a plan's AuthSpec. It supports both ticket and wampcra
148+
// authmethods on the same authid, picking the right key by method.
149+
type staticKeyStore struct {
150+
tickets map[string]TicketUser // authid → ticket user
151+
wampcras map[string]WAMPCRAUser // authid → wampcra user
152+
}
153+
154+
func (ks *staticKeyStore) AuthKey(authid, authmethod string) ([]byte, error) {
155+
switch authmethod {
156+
case "ticket":
157+
u, ok := ks.tickets[authid]
158+
if !ok {
159+
return nil, errors.New("no ticket user: " + authid)
160+
}
161+
return []byte(u.Ticket), nil
162+
case "wampcra":
163+
u, ok := ks.wampcras[authid]
164+
if !ok {
165+
return nil, errors.New("no wampcra user: " + authid)
166+
}
167+
return []byte(u.Secret), nil
168+
}
169+
return nil, errors.New("unsupported authmethod: " + authmethod)
170+
}
171+
172+
func (ks *staticKeyStore) AuthRole(authid string) (string, error) {
173+
if u, ok := ks.tickets[authid]; ok {
174+
return u.Role, nil
175+
}
176+
if u, ok := ks.wampcras[authid]; ok {
177+
return u.Role, nil
178+
}
179+
return "", errors.New("no such user: " + authid)
180+
}
181+
182+
func (ks *staticKeyStore) PasswordInfo(string) (string, int, int) { return "", 0, 0 }
183+
func (ks *staticKeyStore) Provider() string { return "spec-runner-static" }
184+
185+
// buildAuthenticators converts an AuthSpec into the list of
186+
// authenticators to wire into the per-plan router. Returns whether
187+
// anonymous auth should be enabled on the realm and the role to assign
188+
// to anonymous sessions (empty = use realm's default).
189+
func buildAuthenticators(spec *AuthSpec) (anonOn bool, anonRole string, auths []auth.Authenticator) {
190+
if spec == nil {
191+
// Legacy default: anonymous auth, anonymous role.
192+
return true, "", nil
193+
}
194+
195+
ks := &staticKeyStore{
196+
tickets: map[string]TicketUser{},
197+
wampcras: map[string]WAMPCRAUser{},
198+
}
199+
hasTicketUsers := spec.Ticket != nil && len(spec.Ticket.Users) > 0
200+
hasWAMPCRAUsers := spec.WAMPCRA != nil && len(spec.WAMPCRA.Users) > 0
201+
202+
if hasTicketUsers {
203+
for id, u := range spec.Ticket.Users {
204+
ks.tickets[id] = u
205+
}
206+
auths = append(auths, auth.NewTicketAuthenticator(ks, time.Second))
207+
}
208+
if hasWAMPCRAUsers {
209+
for id, u := range spec.WAMPCRA.Users {
210+
ks.wampcras[id] = u
211+
}
212+
auths = append(auths, auth.NewCRAuthenticator(ks, time.Second))
213+
}
214+
if spec.Anonymous != nil {
215+
anonOn = true
216+
anonRole = spec.Anonymous.Role
217+
}
218+
return anonOn, anonRole, auths
219+
}
220+
221+
// roleListForRealm returns the roles a per-plan realm needs to declare
222+
// so that authenticated sessions can be assigned to them. Pulls every
223+
// role mentioned in the auth spec, plus "anonymous" if anonymous auth
224+
// is enabled (defaulting role for anon).
225+
func roleListForRealm(spec *AuthSpec) []string {
226+
if spec == nil {
227+
return []string{"anonymous"}
228+
}
229+
seen := map[string]struct{}{}
230+
add := func(r string) {
231+
if r == "" {
232+
r = "anonymous"
233+
}
234+
seen[r] = struct{}{}
235+
}
236+
if spec.Anonymous != nil {
237+
add(spec.Anonymous.Role)
238+
}
239+
if spec.Ticket != nil {
240+
for _, u := range spec.Ticket.Users {
241+
add(u.Role)
242+
}
243+
}
244+
if spec.WAMPCRA != nil {
245+
for _, u := range spec.WAMPCRA.Users {
246+
add(u.Role)
247+
}
248+
}
249+
out := make([]string, 0, len(seen))
250+
for r := range seen {
251+
out = append(out, r)
252+
}
253+
return out
254+
}
255+
256+
// suppress unused warnings for wamp import if these helpers grow.
257+
var _ = wamp.URI("")

spec/runner/matcher.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,65 @@ func matchPlaceholder(path, ph string, actual any, captures map[string]any) erro
136136
return nil
137137
}
138138

139+
// substituteCaptures walks a YAML-loaded list and replaces any string
140+
// shaped like "{{$name}}" or "{{$name:type}}" with the captured value
141+
// from a previous `expect` step. Used on the send side so a plan can
142+
// echo back ids the router assigned (e.g. yield to an INVOCATION id
143+
// captured from a prior INVOCATION expect).
144+
//
145+
// Returns an error if a placeholder references a name that hasn't been
146+
// captured yet.
147+
func substituteCaptures(v any, captures map[string]any) (any, error) {
148+
switch x := v.(type) {
149+
case string:
150+
if !isPlaceholder(x) {
151+
return x, nil
152+
}
153+
body := strings.TrimSuffix(strings.TrimPrefix(x, "{{$"), "}}")
154+
name, _, _ := strings.Cut(body, ":")
155+
if name == "" {
156+
return nil, fmt.Errorf("send placeholder %q has no name to substitute", x)
157+
}
158+
val, ok := captures[name]
159+
if !ok {
160+
return nil, fmt.Errorf("send placeholder %q references uncaptured name", x)
161+
}
162+
return val, nil
163+
case []any:
164+
out := make([]any, len(x))
165+
for i, item := range x {
166+
r, err := substituteCaptures(item, captures)
167+
if err != nil {
168+
return nil, err
169+
}
170+
out[i] = r
171+
}
172+
return out, nil
173+
case map[string]any:
174+
out := make(map[string]any, len(x))
175+
for k, val := range x {
176+
r, err := substituteCaptures(val, captures)
177+
if err != nil {
178+
return nil, err
179+
}
180+
out[k] = r
181+
}
182+
return out, nil
183+
case map[any]any:
184+
out := make(map[string]any, len(x))
185+
for k, val := range x {
186+
r, err := substituteCaptures(val, captures)
187+
if err != nil {
188+
return nil, err
189+
}
190+
out[fmt.Sprint(k)] = r
191+
}
192+
return out, nil
193+
default:
194+
return v, nil
195+
}
196+
}
197+
139198
// asInt accepts int, int64, float64 (YAML/JSON numeric forms) and returns
140199
// the int64 value.
141200
func asInt(v any) (int64, error) {

0 commit comments

Comments
 (0)