| 1 | diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go |
| 2 | new file mode 100644 |
| 3 | index 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 | +} |
| 252 | diff --git a/internal/gates/gates.go b/internal/gates/gates.go |
| 253 | index 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 | } |