Skip to content

Commit 3019796

Browse files
author
gluck
committed
authz: close the agency surface, fail closed, and serve a unix-socket gateway
Three pre-existing security holes, found by adversarial review of the gateway plan, and the smallest gateway that does not open a new one. C3 -- THE AGENCY METHODS WERE UNGUARDED. authz.Guard wrapped only the angelus handler map. The per-aria hub socket beside it served figaro.qua, figaro.set, study/cast/drop, interrupt and the queue verbs with no policy consulted at all: the entire surface that runs turns and writes boards. ariaHub.guard now wraps hb.handlers(), hubFor always sets it, and an enumeration test walks figaro.AgentMethods() so a method added later cannot slip through by being absent from a list somebody forgot. C1 -- THE POLICY FACTORY FAILED OPEN. handlers.policy() ended in a default arm returning AllowAll, so a config naming a policy this binary did not implement produced a daemon that gated nothing while reading as locked down. config.ValidateAuthz refuses an unknown name before the daemon takes its lock; the factory panics if the two lists ever drift. TestUnknownPolicyNameFallsBackToAllowAll asserted the hole and is rewritten to assert the refusal. C2 -- DENY-BY-DEFAULT WAS NOT REPRESENTABLE. authz.Rules is allow-by-default and a Rule can only deny, so a grant table built on it opened everything when empty. authz.Grants is a separate policy whose ZERO VALUE DENIES, with a two-form glob (exact, or an explicit namespace prefix) and no regex for a mistake to hide in. figaro serve exposes the daemon on a UNIX SOCKET ONLY. A tcp address is refused rather than downgraded: the authenticators and bind-address checks that would make a port safe are not written, and a unix socket inherits the angelus's own "you had to be me to reach it" argument unchanged. Browser origins are denied by default -- CORS does not apply to WebSocket upgrades, so a page with an ambient session cookie could otherwise open a tunnel and speak raw JSON-RPC. The tunnel is a byte pump: it does not parse what it carries, so it cannot drift from the contract and a new method needs no change here. e2e proves it -- a real angelus, a real serve process, and the UNMODIFIED sdk reaching the daemon through the tunnel. Full suite green.
1 parent 4045dd9 commit 3019796

16 files changed

Lines changed: 986 additions & 185 deletions

File tree

‎api/transport/transport.go‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,37 @@ func dialGateway(ep Endpoint) (net.Conn, error) {
105105
if ep.Bearer != "" {
106106
hdr.Set("Authorization", "Bearer "+ep.Bearer)
107107
}
108+
opts := &websocket.DialOptions{HTTPHeader: hdr}
109+
110+
// A gateway on a unix socket is addressed "unix:/path/to.sock". There is
111+
// no host to put in a URL, so we keep a placeholder authority (which the
112+
// server ignores) and teach the HTTP client to dial the socket instead.
113+
// This is the local shape: `figaro serve` binds a unix socket, and a
114+
// reverse proxy -- or a test -- reaches it without a port existing.
115+
addr := ep.Address
116+
if sock, ok := strings.CutPrefix(addr, "unix:"); ok {
117+
opts.HTTPClient = &http.Client{
118+
Transport: &http.Transport{
119+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
120+
var d net.Dialer
121+
return d.DialContext(ctx, "unix", sock)
122+
},
123+
},
124+
}
125+
ws, addr = "ws", "figaro.local"
126+
}
108127

109128
// The handshake gets a deadline; the resulting conn does not inherit it,
110129
// which is why the context is detached below.
111130
ctx, cancel := context.WithTimeout(context.Background(), gatewayHandshake)
112131
defer cancel()
113132

114-
c, resp, err := websocket.Dial(ctx, ws+"://"+ep.Address+"/v1/socket",
115-
&websocket.DialOptions{HTTPHeader: hdr})
133+
c, resp, err := websocket.Dial(ctx, ws+"://"+addr+"/v1/socket", opts)
116134
if err != nil {
117135
if resp != nil {
118136
switch resp.StatusCode {
119137
case http.StatusUnauthorized, http.StatusForbidden:
120-
return nil, fmt.Errorf("gateway %s refused this credential (%s): check `figaro origin`",
138+
return nil, fmt.Errorf("gateway %s refused this credential (%s)",
121139
ep.Address, resp.Status)
122140
case http.StatusNotFound:
123141
return nil, fmt.Errorf("gateway %s has no tunnel at /v1/socket (%s): is that a figaro gateway?",

‎internal/angelus/authz_wiring_test.go‎

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,17 +144,76 @@ func TestTurnActivePredicateHandlesUnknownAria(t *testing.T) {
144144
}
145145
}
146146

147-
// The policy name is data, not a bool: an unknown name must fall back to
148-
// allow-all rather than silently selecting rules nobody asked for.
149-
func TestUnknownPolicyNameFallsBackToAllowAll(t *testing.T) {
150-
hs := authzHandlers(t, true, "no-such-policy", &liveForkFigaro{id: "aria0001", turnActive: true})
151-
params, err := rpc.WithCaller(rpc.ForkRequest{FigaroID: "aria0001"}, "aria0001", nil)
152-
if err != nil {
153-
t.Fatalf("WithCaller: %v", err)
147+
// AN UNKNOWN POLICY NAME MUST NOT FALL BACK TO ALLOW-ALL.
148+
//
149+
// This test asserted the opposite until 2026-08-25, and the behaviour it
150+
// pinned was a critical fail-open: `policy = "rules"` -- a name nobody had
151+
// implemented -- selected allow-all, so a config that read as locked down
152+
// gated nothing. A typo, an older binary, or a policy renamed between
153+
// releases all produced a wide-open daemon that looked configured.
154+
//
155+
// The name is still data, not a bool. But unknown data is a startup error:
156+
// config.ValidateAuthz refuses it before the daemon runs, and the factory
157+
// panics if the two lists ever drift, because failing loudly beats failing
158+
// open.
159+
func TestUnknownPolicyNameIsRefused(t *testing.T) {
160+
l := &config.Loaded{Config: config.Config{
161+
Authz: config.AuthzConfig{Policy: "no-such-policy"},
162+
}}
163+
err := l.ValidateAuthz()
164+
if err == nil {
165+
t.Fatal("an unknown policy name was accepted: it must refuse to start")
154166
}
155-
_, ferr := hs.Map[rpc.MethodFork](context.Background(), params)
156-
if ferr != nil && strings.Contains(ferr.Error(), "deadlock") {
157-
t.Fatalf("unknown policy name gated a request: %v", ferr)
167+
// The refusal has to name the alternatives, or the operator is left
168+
// guessing at a closed vocabulary.
169+
for _, want := range config.KnownAuthzPolicies {
170+
if !strings.Contains(err.Error(), want) {
171+
t.Errorf("refusal does not mention the known policy %q: %v", want, err)
172+
}
173+
}
174+
}
175+
176+
// Every name config accepts, the factory must construct. This is the pairing
177+
// that makes the panic in handlers.policy unreachable in a validated config;
178+
// if it ever fails, one of the two lists moved without the other.
179+
func TestEveryKnownPolicyNameConstructs(t *testing.T) {
180+
for _, name := range config.KnownAuthzPolicies {
181+
l := &config.Loaded{Config: config.Config{Authz: config.AuthzConfig{Policy: name}}}
182+
if err := l.ValidateAuthz(); err != nil {
183+
t.Fatalf("%s: config refuses a name it lists as known: %v", name, err)
184+
}
185+
h := &handlers{config: l}
186+
func() {
187+
defer func() {
188+
if r := recover(); r != nil {
189+
t.Fatalf("%s: config accepts it but the factory panics: %v", name, r)
190+
}
191+
}()
192+
if p := h.policy(); p == nil {
193+
t.Fatalf("%s: factory returned a nil policy", name)
194+
}
195+
}()
196+
}
197+
}
198+
199+
// The `grants` policy must arrive deny-by-default when its table is empty.
200+
// This is the config-level half of authz.TestZeroGrantsDeniesEverything: a
201+
// policy that denies in isolation is worth nothing if the wiring hands the
202+
// daemon an allow-all instead.
203+
func TestGrantsPolicyWithNoTableDenies(t *testing.T) {
204+
l := &config.Loaded{Config: config.Config{
205+
Authz: config.AuthzConfig{Policy: "grants"},
206+
}}
207+
if err := l.ValidateAuthz(); err != nil {
208+
t.Fatalf("grants with an empty table should start (and refuse everything): %v", err)
209+
}
210+
h := &handlers{config: l}
211+
d := h.policy().Check(authz.Request{
212+
Method: rpc.MethodQua,
213+
Identity: authz.Identity{FigaroID: "aria0001", Authenticated: true, Groups: []string{"admin"}},
214+
})
215+
if d.Allow {
216+
t.Fatal("an empty grant table allowed figaro.qua: the table must deny by default")
158217
}
159218
}
160219

‎internal/angelus/hub.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ type ariaHub struct {
5252
ln net.Listener
5353
conns map[*jkrpc.Server]struct{}
5454
agent figaro.AgentServer
55+
// guard wraps the served handler map with authentication and policy. It
56+
// is a field rather than a call in handlers() because the hub is built in
57+
// internal/angelus and the policy is assembled beside the angelus map:
58+
// one guard, both doors.
59+
//
60+
// A NIL GUARD SERVES UNGUARDED, and that is what this endpoint did for
61+
// its whole life before 2026-08-25: figaro.qua, figaro.set, study/cast/
62+
// drop, interrupt and the queue verbs were reachable on an aria socket
63+
// with no policy consulted, while authz.Guard wrapped only the angelus.
64+
// The guarded set must equal the served set, so hubs.go always sets this.
65+
guard func(map[string]jkrpc.HandlerFunc) map[string]jkrpc.HandlerFunc
5566
// Nothing is buffered on purpose: a frame produced while no client is
5667
// attached is owed to nobody, and a client that attaches catches up with
5768
// a read rather than a replay.
@@ -235,6 +246,9 @@ func (hb *ariaHub) handlers() map[string]jkrpc.HandlerFunc {
235246
return hb.route(ctx, method, params)
236247
}
237248
}
249+
if hb.guard != nil {
250+
return hb.guard(out)
251+
}
238252
return out
239253
}
240254

‎internal/angelus/hub_guard_test.go‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package angelus
2+
3+
// THE GUARDED SET MUST EQUAL THE SERVED SET.
4+
//
5+
// Before 2026-08-25 it did not. authz.Guard wrapped the angelus map, and the
6+
// per-aria hub socket beside it served figaro.qua, figaro.set, figaro.study,
7+
// figaro.cast, figaro.drop, figaro.interrupt and the queue verbs with NO
8+
// policy consulted at all -- the entire agency surface, ungated, on a door a
9+
// gateway was about to expose.
10+
//
11+
// These tests fail if that regresses, and they fail by ENUMERATION rather
12+
// than by naming the methods they know about, so a method added later cannot
13+
// slip through by being absent from a list somebody forgot to update.
14+
15+
import (
16+
"context"
17+
"encoding/json"
18+
"testing"
19+
20+
"github.com/jack-work/figaro/api/rpc"
21+
"github.com/jack-work/figaro/internal/authz"
22+
"github.com/jack-work/figaro/internal/figaro"
23+
"github.com/jack-work/jkrpc"
24+
)
25+
26+
// TestHubHandlersAreGuarded walks every method the aria endpoint serves and
27+
// asserts the policy saw it. Enumerated from figaro.AgentMethods(), so a new
28+
// agent method is covered the moment it exists.
29+
func TestHubHandlersAreGuarded(t *testing.T) {
30+
var seen []string
31+
hb := newAriaHub("test", "")
32+
hb.guard = func(m map[string]jkrpc.HandlerFunc) map[string]jkrpc.HandlerFunc {
33+
return authz.Guard(m, authz.AriaHeader{Enabled: false},
34+
authz.PolicyFunc(func(r authz.Request) authz.Decision {
35+
seen = append(seen, r.Method)
36+
return authz.Deny("policy consulted")
37+
}))
38+
}
39+
40+
served := hb.handlers()
41+
methods := figaro.AgentMethods()
42+
if len(served) != len(methods) {
43+
t.Fatalf("hub serves %d methods but AgentMethods lists %d", len(served), len(methods))
44+
}
45+
46+
for _, m := range methods {
47+
h, ok := served[m]
48+
if !ok {
49+
t.Fatalf("%s is in AgentMethods but the hub does not serve it", m)
50+
}
51+
// The handler must refuse WITHOUT reaching route(): a guard that ran
52+
// after dispatch would have already done the thing it was meant to
53+
// prevent. hb has no wake/read/write here, so a leaked call panics or
54+
// errors differently than a policy denial.
55+
_, err := h(context.Background(), json.RawMessage(`{}`))
56+
if err == nil {
57+
t.Fatalf("%s: denied policy still returned success", m)
58+
}
59+
var jerr *jkrpc.Error
60+
if !errorsAs(err, &jerr) || jerr.Code != authz.ErrCode {
61+
t.Fatalf("%s: want an authz refusal, got %v", m, err)
62+
}
63+
}
64+
65+
if len(seen) != len(methods) {
66+
t.Fatalf("policy saw %d methods, hub serves %d: %v", len(seen), len(methods), seen)
67+
}
68+
}
69+
70+
// TestAgencyMethodsAreOnTheGuardedDoor is the regression this whole file
71+
// exists for. These are the methods that grant AGENCY -- they run turns,
72+
// write boards, and rewire what an aria observes -- and each must be served
73+
// by the hub, which is now guarded. A method that quietly moved to an
74+
// unguarded door would pass every other test in the tree.
75+
func TestAgencyMethodsAreOnTheGuardedDoor(t *testing.T) {
76+
agency := []string{
77+
rpc.MethodQua,
78+
rpc.MethodSet,
79+
rpc.MethodStudy,
80+
rpc.MethodDrop,
81+
rpc.MethodCast,
82+
rpc.MethodInterrupt,
83+
rpc.MethodQueueUpdate,
84+
rpc.MethodQueueDelete,
85+
}
86+
served := map[string]bool{}
87+
for _, m := range figaro.AgentMethods() {
88+
served[m] = true
89+
}
90+
for _, m := range agency {
91+
if !served[m] {
92+
t.Errorf("%s grants agency but is not on the aria endpoint: "+
93+
"if it moved, confirm its new door is guarded", m)
94+
}
95+
}
96+
}
97+
98+
// A nil guard serves unguarded. That is the pre-fix behaviour, kept only so
99+
// the hub can be built in tests without a policy -- and pinned here so nobody
100+
// mistakes it for the production path. hubFor always sets a guard.
101+
func TestNilGuardServesUnguarded(t *testing.T) {
102+
hb := newAriaHub("test", "")
103+
if hb.guard != nil {
104+
t.Fatal("a freshly built hub should carry no guard until hubFor sets one")
105+
}
106+
if len(hb.handlers()) != len(figaro.AgentMethods()) {
107+
t.Fatal("an unguarded hub should still serve the full method set")
108+
}
109+
}
110+
111+
func errorsAs(err error, target **jkrpc.Error) bool {
112+
e, ok := err.(*jkrpc.Error)
113+
if ok {
114+
*target = e
115+
}
116+
return ok
117+
}

‎internal/angelus/hubs.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ func (h *handlers) hubFor(id string) (*ariaHub, error) {
7272
hb.read = h.readFromStore
7373
hb.write = h.writeForHub
7474
hb.dress = h.dressParams
75+
// The aria socket serves the AGENCY methods -- qua, set, study, cast,
76+
// interrupt, the queue verbs -- and until this line existed it served
77+
// them with no policy at all, while the angelus door beside it was
78+
// guarded. One guard, both doors: the guarded set is now the served set.
79+
hb.guard = h.guardHandlers
7580
if h.angelus.Backend != nil {
7681
if n, ok := h.angelus.Backend.Node(id); ok {
7782
hb.kind = n.Kind

‎internal/angelus/protocol.go‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,20 +134,54 @@ func (h *handlers) authenticator() authz.Authenticator {
134134
return authz.AriaHeader{Enabled: h.config != nil && h.config.CallerIdentityEnabled()}
135135
}
136136

137+
// guardHandlers wraps a handler map with THIS daemon's authn and policy. It is
138+
// handed to every aria hub so the agency methods are gated by the same seam
139+
// that gates the angelus door: see ariaHub.guard for what it was like before.
140+
func (h *handlers) guardHandlers(m map[string]jkrpc.HandlerFunc) map[string]jkrpc.HandlerFunc {
141+
return authz.Guard(m, h.authenticator(), h.policy())
142+
}
143+
137144
// policy builds the configured authorization policy. The turn-active predicate
138145
// is read off the live registry: a dormant or unknown aria has no turn in
139146
// flight, so it cannot be mid-turn, and a fork of it is free to proceed.
147+
//
148+
// AN UNKNOWN NAME IS FATAL, and that is the whole point of the panic below.
149+
// This switch used to end in `default: return AllowAll()`, so a config naming
150+
// a policy this binary did not know -- a typo, an old binary, a policy not yet
151+
// written -- silently opened the daemon completely. Configuration validation
152+
// rejects unknown names before we get here (config.AuthzPolicy), so reaching
153+
// this line means the two lists have drifted, and failing loudly beats failing
154+
// open.
140155
func (h *handlers) policy() authz.Policy {
141156
name := "allow-all"
142157
if h.config != nil {
143158
name = h.config.AuthzPolicy()
144159
}
145160
switch name {
161+
case "allow-all":
162+
return authz.AllowAll()
146163
case "default":
147164
return authz.DefaultRules(h.turnActive)
165+
case "grants":
166+
return h.grantsPolicy()
148167
default:
149-
return authz.AllowAll()
168+
panic(fmt.Sprintf("authz policy %q is not known to this binary: "+
169+
"config validation should have refused it (internal/config.AuthzPolicy)", name))
170+
}
171+
}
172+
173+
// grantsPolicy is the deny-by-default table. Its ZERO VALUE DENIES: an empty
174+
// or unparsed grant list refuses everything rather than allowing everything,
175+
// which is the opposite of what authz.Rules does and the reason grants are a
176+
// separate policy type rather than more Rules.
177+
func (h *handlers) grantsPolicy() authz.Policy {
178+
var grants []authz.Grant
179+
if h.config != nil {
180+
for _, g := range h.config.Config.Authz.Grants {
181+
grants = append(grants, authz.Grant{Groups: g.Groups, Methods: g.Methods})
182+
}
150183
}
184+
return authz.Grants{Table: grants, Extra: authz.DefaultRules(h.turnActive)}
151185
}
152186

153187
func (h *handlers) turnActive(ariaID string) bool {

‎internal/authz/authz.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ type Identity struct {
2828
// Label is an ASSERTED caller name (rpc.CallerLabelKey / FIGARO_CALLER),
2929
// carried for ATTRIBUTION ONLY.
3030
Label string
31+
// Groups the authenticator vouched for. Empty for every credential that
32+
// carries no group claim, which is every one of them today except the
33+
// gateway's upstream authenticator (Remote-Groups). A grant table keys on
34+
// these, so an authenticator that cannot vouch for a group can never
35+
// satisfy one -- which is the correct failure direction.
36+
Groups []string
3137
}
3238

3339
// Attribution renders who is speaking, for the model and for the UI.

0 commit comments

Comments
 (0)