- commit
- 3e13e858ec6f4c01975a4ad56ad678a51e73f5fe
- parent
- 568610dff70a20c0f45dedb1e34677e2f5a76b37
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 245 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 100 | ++++++++++++- |
@@ -0,0 +1,245 @@
1+package gates2+3+import (4+ "context"5+ "database/sql"6+ "encoding/json"7+ "strconv"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/community"12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/policy"14+ "github.com/FelineStateMachine/tinyrelay/internal/relay"15+ "github.com/FelineStateMachine/tinyrelay/internal/storage"16+)17+18+const (19+ agentOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"20+ agentSecret = "0000000000000000000000000000000000000000000000000000000000000002"21+ agentOtherSecret = "0000000000000000000000000000000000000000000000000000000000000003"22+ agentNow = int64(1_700_000_000)23+)24+25+type agentFixture struct {26+ gate *Gate27+ community *community.Service28+ store *storage.Store29+ owner string30+ agent string31+}32+33+func newAgentFixture(t *testing.T) agentFixture {34+ t.Helper()35+ ctx := context.Background()36+ store, err := storage.Open(ctx, t.TempDir()+"/relay.db")37+ if err != nil {38+ t.Fatal(err)39+ }40+ t.Cleanup(func() { _ = store.Close() })41+ owner, _ := event.PublicKey(agentOwnerSecret)42+ agent, _ := event.PublicKey(agentSecret)43+ svc, err := community.New(ctx, store, owner)44+ if err != nil {45+ t.Fatal(err)46+ }47+ p := policy.Defaults(owner)48+ gate, err := New(Config{Store: store, Community: svc, Policy: func() policy.Policy { return p }, Slug: "general"})49+ if err != nil {50+ t.Fatal(err)51+ }52+ return agentFixture{gate: gate, community: svc, store: store, owner: owner, agent: agent}53+}54+55+func signed(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event {56+ t.Helper()57+ if tags == nil {58+ tags = [][]string{}59+ }60+ e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content}61+ if err := event.Sign(&e, secret); err != nil {62+ t.Fatal(err)63+ }64+ return e65+}66+67+func grantTags(agent string, expires int64, extra ...[]string) [][]string {68+ tags := [][]string{{"d", agent}, {"p", agent}, {"name", "helper"}, {"expiration", strconv.FormatInt(expires, 10)}}69+ return append(tags, extra...)70+}71+72+// grant publishes a grant through the gate and mirrors it as the tenant73+// would. Later grants for the same agent need a later createdAt to replace74+// the earlier one.75+func (f agentFixture) grant(t *testing.T, createdAt int64, tags [][]string) event.Event {76+ t.Helper()77+ ctx := context.Background()78+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, createdAt, tags, "")79+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {80+ t.Fatalf("grant rejected: %v", err)81+ }82+ if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {83+ if _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow}); err != nil {84+ return err85+ }86+ return f.community.ApplyAgentEventTx(ctx, tx, e, agentNow)87+ }); err != nil {88+ t.Fatal(err)89+ }90+ return e91+}92+93+func expectRestricted(t *testing.T, err error, fragment string) {94+ t.Helper()95+ if err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant") || !strings.Contains(err.Error(), fragment) {96+ t.Fatalf("want restricted agent grant error containing %q, got %v", fragment, err)97+ }98+}99+100+func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {101+ f := newAgentFixture(t)102+ ctx := context.Background()103+ repo := f.owner + ":project"104+ 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"}))105+ if role, _ := f.community.Role(ctx, f.agent); role != "agent" {106+ t.Fatalf("role = %q, want agent", role)107+ }108+ session := relay.Session{PubKeys: []string{f.agent}}109+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, nil, "hello"), session, agentNow); err != nil {110+ t.Fatalf("allowed kind rejected: %v", err)111+ }112+ if err := f.gate.Write(ctx, signed(t, agentSecret, 0, agentNow, nil, "{}"), session, agentNow); err != nil {113+ t.Fatalf("profile rejected: %v", err)114+ }115+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), session, agentNow), "kind 7")116+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, [][]string{{"h", "general"}}, "in room"), session, agentNow); err != nil {117+ t.Fatalf("allowed room rejected: %v", err)118+ }119+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, [][]string{{"h", "other"}}, "wrong room"), session, agentNow), "room other")120+ address := "30617:" + repo121+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, [][]string{{"a", address}}, "issue"), session, agentNow); err != nil {122+ t.Fatalf("issue in readable repository rejected: %v", err)123+ }124+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1111, agentNow, [][]string{{"A", address}}, "comment"), session, agentNow); err != nil {125+ t.Fatalf("comment in readable repository rejected: %v", err)126+ }127+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")128+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, [][]string{{"a", "30617:" + f.owner + ":elsewhere"}}, "issue"), session, agentNow), "repository")129+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, nil, "issue"), session, agentNow), "repository address")130+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":maintain"}, []string{"k", "1630"}))131+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {132+ t.Fatalf("maintainer status rejected: %v", err)133+ }134+}135+136+func TestAgentGrantStateRejectsWrites(t *testing.T) {137+ f := newAgentFixture(t)138+ ctx := context.Background()139+ session := relay.Session{PubKeys: []string{f.agent}}140+ note := signed(t, agentSecret, 1, agentNow, nil, "hello")141+ f.grant(t, agentNow, grantTags(f.agent, agentNow+60, []string{"k", "1"}))142+ if err := f.gate.Write(ctx, note, session, agentNow); err != nil {143+ t.Fatalf("active grant rejected: %v", err)144+ }145+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow+61), "expired")146+ if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil {147+ t.Fatal(err)148+ }149+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "paused")150+ if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, false); err != nil {151+ t.Fatal(err)152+ }153+ if err := f.gate.Write(ctx, note, session, agentNow); err != nil {154+ t.Fatalf("resumed grant rejected: %v", err)155+ }156+ if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil {157+ t.Fatal(err)158+ }159+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "revoked")160+ if role, _ := f.community.Role(ctx, f.agent); role != "" {161+ t.Fatalf("revoked agent still has role %q", role)162+ }163+ f.grant(t, agentNow+1, grantTags(f.agent, agentNow-1, []string{"k", "1"}))164+ expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "revoked")165+}166+167+func TestAgentGrantRateLimitSlidesOverOneMinute(t *testing.T) {168+ f := newAgentFixture(t)169+ ctx := context.Background()170+ session := relay.Session{PubKeys: []string{f.agent}}171+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}, []string{"rate", "2"}))172+ for i := 0; i < 2; i++ {173+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+int64(i), nil, "n"), session, agentNow+int64(i)); err != nil {174+ t.Fatalf("event %d rejected: %v", i, err)175+ }176+ }177+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+2, nil, "n"), session, agentNow+2), "2 events per minute")178+ if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+61, nil, "n"), session, agentNow+61); err != nil {179+ t.Fatalf("event after the window rejected: %v", err)180+ }181+}182+183+func TestHumanMemberWithGrantKeepsMemberPermissions(t *testing.T) {184+ f := newAgentFixture(t)185+ ctx := context.Background()186+ raw, _ := json.Marshal(f.agent)187+ if _, err := f.community.Execute(ctx, f.owner, "setmember", []json.RawMessage{raw}); err != nil {188+ t.Fatal(err)189+ }190+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))191+ if role, _ := f.community.Role(ctx, f.agent); role != "member" {192+ t.Fatalf("grant changed a human role to %q", role)193+ }194+ if err := f.gate.Write(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), relay.Session{PubKeys: []string{f.agent}}, agentNow); err != nil {195+ t.Fatalf("member constrained by grant: %v", err)196+ }197+}198+199+func TestImportEnforcesAgentGrant(t *testing.T) {200+ f := newAgentFixture(t)201+ ctx := context.Background()202+ f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))203+ if err := f.gate.Import(ctx, signed(t, agentSecret, 1, agentNow, nil, "synced"), agentNow); err != nil {204+ t.Fatalf("allowed import rejected: %v", err)205+ }206+ expectRestricted(t, f.gate.Import(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), agentNow), "kind 7")207+}208+209+func TestAgentGrantShapeAndSigner(t *testing.T) {210+ f := newAgentFixture(t)211+ ctx := context.Background()212+ other, _ := event.PublicKey(agentOtherSecret)213+ cases := []struct {214+ name string215+ secret string216+ tags [][]string217+ want string218+ }{219+ {"stranger signer", agentOtherSecret, grantTags(f.agent, agentNow+60), "restricted: only the owner or a moderator"},220+ {"missing expiration", agentOwnerSecret, [][]string{{"d", f.agent}, {"p", f.agent}}, "invalid: agent grant needs an expiration"},221+ {"too far out", agentOwnerSecret, grantTags(f.agent, agentNow+366*86400), "invalid: agent grant expiration"},222+ {"mismatched p", agentOwnerSecret, [][]string{{"d", f.agent}, {"p", other}, {"expiration", "1700003600"}}, "invalid: agent grant p tag"},223+ {"self grant", agentOwnerSecret, grantTags(f.owner, agentNow+60), "invalid: agent grant may not name its signer"},224+ {"bad repo", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"repo", "nope"}), "invalid: agent grant repo tag"},225+ {"bad rate", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"rate", "601"}), "invalid: agent grant rate"},226+ {"bad wiki", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"wiki", "admin"}), "invalid: agent grant wiki tag"},227+ }228+ for _, tc := range cases {229+ t.Run(tc.name, func(t *testing.T) {230+ e := signed(t, tc.secret, event.KIND_AGENT_GRANT, agentNow, tc.tags, "")231+ err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{e.PubKey}}, agentNow)232+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) {233+ t.Fatalf("want %q, got %v", tc.want, err)234+ }235+ })236+ }237+ e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"k", "1"}, []string{"wiki", "propose"}), `{"note":"ok"}`)238+ if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {239+ t.Fatalf("valid grant rejected: %v", err)240+ }241+ grant, err := community.ParseAgentGrant(e, agentNow)242+ if err != nil || grant.Scope.Rate != community.AgentRateDefault || grant.Scope.Wiki != "propose" || grant.Name != "helper" {243+ t.Fatalf("parsed grant %+v %v", grant, err)244+ }245+}@@ -6,6 +6,7 @@ import (
66 "errors"77 "fmt"88 "strings"9+ "sync"910 1011 "github.com/FelineStateMachine/tinyrelay/internal/community"1112 "github.com/FelineStateMachine/tinyrelay/internal/event"@@ -22,7 +23,88 @@ type Config struct {
2223 Slug string2324 }2425 25-type Gate struct{ cfg Config }26+type Gate struct {27+ cfg Config28+ agents agentLimiter29+}30+31+// agentLimiter is the per-agent sliding one-minute counter. It lives in32+// memory: a restart forgives the last minute, which is the cheap and honest33+// trade for a limit that never touches the database on the hot path.34+type agentLimiter struct {35+ mu sync.Mutex36+ recent map[string][]int6437+}38+39+func (l *agentLimiter) allow(agent string, limit int, now int64) bool {40+ l.mu.Lock()41+ defer l.mu.Unlock()42+ if l.recent == nil {43+ l.recent = map[string][]int64{}44+ }45+ kept := l.recent[agent][:0]46+ for _, at := range l.recent[agent] {47+ if at > now-60 {48+ kept = append(kept, at)49+ }50+ }51+ if len(kept) >= limit {52+ l.recent[agent] = kept53+ return false54+ }55+ l.recent[agent] = append(kept, now)56+ return true57+}58+59+// agentAdmission applies an agent grant to an event from a key whose only60+// standing here is that grant. Keys with a human role keep that role's61+// permissions. Events from agents are never stamped or rewritten; they are62+// kept as signed or refused with a restricted reason.63+func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) error {64+ if g.cfg.Community == nil {65+ return nil66+ }67+ grant, ok, err := g.cfg.Community.AgentGrant(ctx, e.PubKey)68+ if err != nil {69+ return fmt.Errorf("check agent grant: %w", err)70+ }71+ if !ok {72+ return nil73+ }74+ role, err := g.cfg.Community.Role(ctx, e.PubKey)75+ if err != nil {76+ return fmt.Errorf("check agent role: %w", err)77+ }78+ if role != "" && role != "agent" {79+ return nil80+ }81+ if err := grant.Check(e, now); err != nil {82+ return err83+ }84+ if !g.agents.allow(e.PubKey, grant.Scope.Rate, now) {85+ return fmt.Errorf("restricted: agent grant does not allow more than %d events per minute", grant.Scope.Rate)86+ }87+ return nil88+}89+90+// agentGrantShape checks a grant event's signer and tags before it is stored.91+func (g *Gate) agentGrantShape(ctx context.Context, e event.Event, now int64) error {92+ if e.Kind != event.KIND_AGENT_GRANT {93+ return nil94+ }95+ if g.cfg.Community == nil {96+ return errors.New("restricted: agent grants need the membership service")97+ }98+ role, err := g.cfg.Community.Role(ctx, e.PubKey)99+ if err != nil {100+ return fmt.Errorf("check grant signer: %w", err)101+ }102+ if !community.CanGrantAgents(role) {103+ return errors.New("restricted: only the owner or a moderator can grant an agent")104+ }105+ _, err = community.ParseAgentGrant(e, now)106+ return err107+}26108 27109 func New(cfg Config) (*Gate, error) {28110 if cfg.Policy == nil {@@ -67,7 +149,7 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
67149 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {68150 return errors.New("invalid: event creation date is too far off from the current time")69151 }70- if exp := event.Expiration(e); exp > 0 && exp <= now {152+ if exp := event.Expiration(e); exp > 0 && exp <= now && e.Kind != event.KIND_AGENT_GRANT {71153 return errors.New("invalid: event has already expired")72154 }73155 if g.cfg.Community != nil {@@ -95,6 +177,12 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
95177 return errors.New("blocked: this event is banned")96178 }97179 }180+ if err := g.agentGrantShape(ctx, e, now); err != nil {181+ return err182+ }183+ if err := g.agentAdmission(ctx, e, now); err != nil {184+ return err185+ }98186 if where := blockedWord(p, e); where != "" && !g.isModerator(ctx, e.PubKey) {99187 return errors.New("blocked: " + where)100188 }@@ -197,7 +285,7 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
197285 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {198286 return errors.New("invalid: event creation date is too far off from the current time")199287 }200- if exp := event.Expiration(e); exp > 0 && exp <= now {288+ if exp := event.Expiration(e); exp > 0 && exp <= now && e.Kind != event.KIND_AGENT_GRANT {201289 return errors.New("invalid: event has already expired")202290 }203291 if g.cfg.Community != nil {@@ -216,6 +304,12 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
216304 return errors.New("blocked: this event is banned")217305 }218306 }307+ if err := g.agentGrantShape(ctx, e, now); err != nil {308+ return err309+ }310+ if err := g.agentAdmission(ctx, e, now); err != nil {311+ return err312+ }219313 if where := blockedWord(p, e); where != "" && !g.isModerator(ctx, e.PubKey) {220314 return errors.New("blocked: " + where)221315 }