tinyrelaycommit

Commit 3e13e858ec6f

browse files at this commit | history

1diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go
2new file mode 100644
3index 0000000..69be806
4--- /dev/null
5+++ b/internal/gates/agents_test.go
6@@ -0,0 +1,245 @@
7+package gates
8+
9+import (
10+ "context"
11+ "database/sql"
12+ "encoding/json"
13+ "strconv"
14+ "strings"
15+ "testing"
16+
17+ "github.com/FelineStateMachine/tinyrelay/internal/community"
18+ "github.com/FelineStateMachine/tinyrelay/internal/event"
19+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
20+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
21+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
22+)
23+
24+const (
25+ agentOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"
26+ agentSecret = "0000000000000000000000000000000000000000000000000000000000000002"
27+ agentOtherSecret = "0000000000000000000000000000000000000000000000000000000000000003"
28+ agentNow = int64(1_700_000_000)
29+)
30+
31+type agentFixture struct {
32+ gate *Gate
33+ community *community.Service
34+ store *storage.Store
35+ owner string
36+ agent string
37+}
38+
39+func newAgentFixture(t *testing.T) agentFixture {
40+ t.Helper()
41+ ctx := context.Background()
42+ store, err := storage.Open(ctx, t.TempDir()+"/relay.db")
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ t.Cleanup(func() { _ = store.Close() })
47+ owner, _ := event.PublicKey(agentOwnerSecret)
48+ agent, _ := event.PublicKey(agentSecret)
49+ svc, err := community.New(ctx, store, owner)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ p := policy.Defaults(owner)
54+ gate, err := New(Config{Store: store, Community: svc, Policy: func() policy.Policy { return p }, Slug: "general"})
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ return agentFixture{gate: gate, community: svc, store: store, owner: owner, agent: agent}
59+}
60+
61+func signed(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event {
62+ t.Helper()
63+ if tags == nil {
64+ tags = [][]string{}
65+ }
66+ e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content}
67+ if err := event.Sign(&e, secret); err != nil {
68+ t.Fatal(err)
69+ }
70+ return e
71+}
72+
73+func grantTags(agent string, expires int64, extra ...[]string) [][]string {
74+ tags := [][]string{{"d", agent}, {"p", agent}, {"name", "helper"}, {"expiration", strconv.FormatInt(expires, 10)}}
75+ return append(tags, extra...)
76+}
77+
78+// grant publishes a grant through the gate and mirrors it as the tenant
79+// would. Later grants for the same agent need a later createdAt to replace
80+// the earlier one.
81+func (f agentFixture) grant(t *testing.T, createdAt int64, tags [][]string) event.Event {
82+ t.Helper()
83+ ctx := context.Background()
84+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, createdAt, tags, "")
85+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {
86+ t.Fatalf("grant rejected: %v", err)
87+ }
88+ if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {
89+ if _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow}); err != nil {
90+ return err
91+ }
92+ return f.community.ApplyAgentEventTx(ctx, tx, e, agentNow)
93+ }); err != nil {
94+ t.Fatal(err)
95+ }
96+ return e
97+}
98+
99+func expectRestricted(t *testing.T, err error, fragment string) {
100+ t.Helper()
101+ if err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant") || !strings.Contains(err.Error(), fragment) {
102+ t.Fatalf("want restricted agent grant error containing %q, got %v", fragment, err)
103+ }
104+}
105+
106+func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {
107+ f := newAgentFixture(t)
108+ ctx := context.Background()
109+ repo := f.owner + ":project"
110+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"room", "general"}, []string{"repo", repo + ":read"}, []string{"k", "1"}, []string{"k", "1621"}, []string{"k", "1630"}, []string{"k", "1111"}))
111+ if role, _ := f.community.Role(ctx, f.agent); role != "agent" {
112+ t.Fatalf("role = %q, want agent", role)
113+ }
114+ session := relay.Session{PubKeys: []string{f.agent}}
115+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, nil, "hello"), session, agentNow); err != nil {
116+ t.Fatalf("allowed kind rejected: %v", err)
117+ }
118+ if err := f.gate.Write(ctx, signed(t, agentSecret, 0, agentNow, nil, "{}"), session, agentNow); err != nil {
119+ t.Fatalf("profile rejected: %v", err)
120+ }
121+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), session, agentNow), "kind 7")
122+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, [][]string{{"h", "general"}}, "in room"), session, agentNow); err != nil {
123+ t.Fatalf("allowed room rejected: %v", err)
124+ }
125+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, [][]string{{"h", "other"}}, "wrong room"), session, agentNow), "room other")
126+ address := "30617:" + repo
127+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, [][]string{{"a", address}}, "issue"), session, agentNow); err != nil {
128+ t.Fatalf("issue in readable repository rejected: %v", err)
129+ }
130+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1111, agentNow, [][]string{{"A", address}}, "comment"), session, agentNow); err != nil {
131+ t.Fatalf("comment in readable repository rejected: %v", err)
132+ }
133+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")
134+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, [][]string{{"a", "30617:" + f.owner + ":elsewhere"}}, "issue"), session, agentNow), "repository")
135+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, nil, "issue"), session, agentNow), "repository address")
136+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":maintain"}, []string{"k", "1630"}))
137+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {
138+ t.Fatalf("maintainer status rejected: %v", err)
139+ }
140+}
141+
142+func TestAgentGrantStateRejectsWrites(t *testing.T) {
143+ f := newAgentFixture(t)
144+ ctx := context.Background()
145+ session := relay.Session{PubKeys: []string{f.agent}}
146+ note := signed(t, agentSecret, 1, agentNow, nil, "hello")
147+ f.grant(t, agentNow, grantTags(f.agent, agentNow+60, []string{"k", "1"}))
148+ if err := f.gate.Write(ctx, note, session, agentNow); err != nil {
149+ t.Fatalf("active grant rejected: %v", err)
150+ }
151+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow+61), "expired")
152+ if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil {
153+ t.Fatal(err)
154+ }
155+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "paused")
156+ if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, false); err != nil {
157+ t.Fatal(err)
158+ }
159+ if err := f.gate.Write(ctx, note, session, agentNow); err != nil {
160+ t.Fatalf("resumed grant rejected: %v", err)
161+ }
162+ if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil {
163+ t.Fatal(err)
164+ }
165+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "revoked")
166+ if role, _ := f.community.Role(ctx, f.agent); role != "" {
167+ t.Fatalf("revoked agent still has role %q", role)
168+ }
169+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow-1, []string{"k", "1"}))
170+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "revoked")
171+}
172+
173+func TestAgentGrantRateLimitSlidesOverOneMinute(t *testing.T) {
174+ f := newAgentFixture(t)
175+ ctx := context.Background()
176+ session := relay.Session{PubKeys: []string{f.agent}}
177+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}, []string{"rate", "2"}))
178+ for i := 0; i < 2; i++ {
179+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+int64(i), nil, "n"), session, agentNow+int64(i)); err != nil {
180+ t.Fatalf("event %d rejected: %v", i, err)
181+ }
182+ }
183+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+2, nil, "n"), session, agentNow+2), "2 events per minute")
184+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+61, nil, "n"), session, agentNow+61); err != nil {
185+ t.Fatalf("event after the window rejected: %v", err)
186+ }
187+}
188+
189+func TestHumanMemberWithGrantKeepsMemberPermissions(t *testing.T) {
190+ f := newAgentFixture(t)
191+ ctx := context.Background()
192+ raw, _ := json.Marshal(f.agent)
193+ if _, err := f.community.Execute(ctx, f.owner, "setmember", []json.RawMessage{raw}); err != nil {
194+ t.Fatal(err)
195+ }
196+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))
197+ if role, _ := f.community.Role(ctx, f.agent); role != "member" {
198+ t.Fatalf("grant changed a human role to %q", role)
199+ }
200+ if err := f.gate.Write(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), relay.Session{PubKeys: []string{f.agent}}, agentNow); err != nil {
201+ t.Fatalf("member constrained by grant: %v", err)
202+ }
203+}
204+
205+func TestImportEnforcesAgentGrant(t *testing.T) {
206+ f := newAgentFixture(t)
207+ ctx := context.Background()
208+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))
209+ if err := f.gate.Import(ctx, signed(t, agentSecret, 1, agentNow, nil, "synced"), agentNow); err != nil {
210+ t.Fatalf("allowed import rejected: %v", err)
211+ }
212+ expectRestricted(t, f.gate.Import(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), agentNow), "kind 7")
213+}
214+
215+func TestAgentGrantShapeAndSigner(t *testing.T) {
216+ f := newAgentFixture(t)
217+ ctx := context.Background()
218+ other, _ := event.PublicKey(agentOtherSecret)
219+ cases := []struct {
220+ name string
221+ secret string
222+ tags [][]string
223+ want string
224+ }{
225+ {"stranger signer", agentOtherSecret, grantTags(f.agent, agentNow+60), "restricted: only the owner or a moderator"},
226+ {"missing expiration", agentOwnerSecret, [][]string{{"d", f.agent}, {"p", f.agent}}, "invalid: agent grant needs an expiration"},
227+ {"too far out", agentOwnerSecret, grantTags(f.agent, agentNow+366*86400), "invalid: agent grant expiration"},
228+ {"mismatched p", agentOwnerSecret, [][]string{{"d", f.agent}, {"p", other}, {"expiration", "1700003600"}}, "invalid: agent grant p tag"},
229+ {"self grant", agentOwnerSecret, grantTags(f.owner, agentNow+60), "invalid: agent grant may not name its signer"},
230+ {"bad repo", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"repo", "nope"}), "invalid: agent grant repo tag"},
231+ {"bad rate", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"rate", "601"}), "invalid: agent grant rate"},
232+ {"bad wiki", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"wiki", "admin"}), "invalid: agent grant wiki tag"},
233+ }
234+ for _, tc := range cases {
235+ t.Run(tc.name, func(t *testing.T) {
236+ e := signed(t, tc.secret, event.KIND_AGENT_GRANT, agentNow, tc.tags, "")
237+ err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{e.PubKey}}, agentNow)
238+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) {
239+ t.Fatalf("want %q, got %v", tc.want, err)
240+ }
241+ })
242+ }
243+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"k", "1"}, []string{"wiki", "propose"}), `{"note":"ok"}`)
244+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {
245+ t.Fatalf("valid grant rejected: %v", err)
246+ }
247+ grant, err := community.ParseAgentGrant(e, agentNow)
248+ if err != nil || grant.Scope.Rate != community.AgentRateDefault || grant.Scope.Wiki != "propose" || grant.Name != "helper" {
249+ t.Fatalf("parsed grant %+v %v", grant, err)
250+ }
251+}
252diff --git a/internal/gates/gates.go b/internal/gates/gates.go
253index fb1a02c..46db622 100644
254--- a/internal/gates/gates.go
255+++ b/internal/gates/gates.go
256@@ -6,6 +6,7 @@ import (
257 "errors"
258 "fmt"
259 "strings"
260+ "sync"
261
262 "github.com/FelineStateMachine/tinyrelay/internal/community"
263 "github.com/FelineStateMachine/tinyrelay/internal/event"
264@@ -22,7 +23,88 @@ type Config struct {
265 Slug string
266 }
267
268-type Gate struct{ cfg Config }
269+type Gate struct {
270+ cfg Config
271+ agents agentLimiter
272+}
273+
274+// agentLimiter is the per-agent sliding one-minute counter. It lives in
275+// memory: a restart forgives the last minute, which is the cheap and honest
276+// trade for a limit that never touches the database on the hot path.
277+type agentLimiter struct {
278+ mu sync.Mutex
279+ recent map[string][]int64
280+}
281+
282+func (l *agentLimiter) allow(agent string, limit int, now int64) bool {
283+ l.mu.Lock()
284+ defer l.mu.Unlock()
285+ if l.recent == nil {
286+ l.recent = map[string][]int64{}
287+ }
288+ kept := l.recent[agent][:0]
289+ for _, at := range l.recent[agent] {
290+ if at > now-60 {
291+ kept = append(kept, at)
292+ }
293+ }
294+ if len(kept) >= limit {
295+ l.recent[agent] = kept
296+ return false
297+ }
298+ l.recent[agent] = append(kept, now)
299+ return true
300+}
301+
302+// agentAdmission applies an agent grant to an event from a key whose only
303+// standing here is that grant. Keys with a human role keep that role's
304+// permissions. Events from agents are never stamped or rewritten; they are
305+// kept as signed or refused with a restricted reason.
306+func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) error {
307+ if g.cfg.Community == nil {
308+ return nil
309+ }
310+ grant, ok, err := g.cfg.Community.AgentGrant(ctx, e.PubKey)
311+ if err != nil {
312+ return fmt.Errorf("check agent grant: %w", err)
313+ }
314+ if !ok {
315+ return nil
316+ }
317+ role, err := g.cfg.Community.Role(ctx, e.PubKey)
318+ if err != nil {
319+ return fmt.Errorf("check agent role: %w", err)
320+ }
321+ if role != "" && role != "agent" {
322+ return nil
323+ }
324+ if err := grant.Check(e, now); err != nil {
325+ return err
326+ }
327+ if !g.agents.allow(e.PubKey, grant.Scope.Rate, now) {
328+ return fmt.Errorf("restricted: agent grant does not allow more than %d events per minute", grant.Scope.Rate)
329+ }
330+ return nil
331+}
332+
333+// agentGrantShape checks a grant event's signer and tags before it is stored.
334+func (g *Gate) agentGrantShape(ctx context.Context, e event.Event, now int64) error {
335+ if e.Kind != event.KIND_AGENT_GRANT {
336+ return nil
337+ }
338+ if g.cfg.Community == nil {
339+ return errors.New("restricted: agent grants need the membership service")
340+ }
341+ role, err := g.cfg.Community.Role(ctx, e.PubKey)
342+ if err != nil {
343+ return fmt.Errorf("check grant signer: %w", err)
344+ }
345+ if !community.CanGrantAgents(role) {
346+ return errors.New("restricted: only the owner or a moderator can grant an agent")
347+ }
348+ _, err = community.ParseAgentGrant(e, now)
349+ return err
350+}
351
352 func New(cfg Config) (*Gate, error) {
353 if cfg.Policy == nil {
354@@ -67,7 +149,7 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
355 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {
356 return errors.New("invalid: event creation date is too far off from the current time")
357 }
358- if exp := event.Expiration(e); exp > 0 && exp <= now {
359+ if exp := event.Expiration(e); exp > 0 && exp <= now && e.Kind != event.KIND_AGENT_GRANT {
360 return errors.New("invalid: event has already expired")
361 }
362 if g.cfg.Community != nil {
363@@ -95,6 +177,12 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
364 return errors.New("blocked: this event is banned")
365 }
366 }
367+ if err := g.agentGrantShape(ctx, e, now); err != nil {
368+ return err
369+ }
370+ if err := g.agentAdmission(ctx, e, now); err != nil {
371+ return err
372+ }
373 if where := blockedWord(p, e); where != "" && !g.isModerator(ctx, e.PubKey) {
374 return errors.New("blocked: " + where)
375 }
376@@ -197,7 +285,7 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
377 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {
378 return errors.New("invalid: event creation date is too far off from the current time")
379 }
380- if exp := event.Expiration(e); exp > 0 && exp <= now {
381+ if exp := event.Expiration(e); exp > 0 && exp <= now && e.Kind != event.KIND_AGENT_GRANT {
382 return errors.New("invalid: event has already expired")
383 }
384 if g.cfg.Community != nil {
385@@ -216,6 +304,12 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
386 return errors.New("blocked: this event is banned")
387 }
388 }
389+ if err := g.agentGrantShape(ctx, e, now); err != nil {
390+ return err
391+ }
392+ if err := g.agentAdmission(ctx, e, now); err != nil {
393+ return err
394+ }
395 if where := blockedWord(p, e); where != "" && !g.isModerator(ctx, e.PubKey) {
396 return errors.New("blocked: " + where)
397 }

About

ownerc01503e8035f
visibilitypublic
head3e13e858ec6f

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs