clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

enforce agent grants at the write and import gates

Dami committed 3e13e85
commit
3e13e858ec6f4c01975a4ad56ad678a51e73f5fe
parent
568610dff70a20c0f45dedb1e34677e2f5a76b37
author
Dami <dami.etoile@gmail.com>
2 files changed, +342 -3
Ainternal/gates/agents_test.go245++++++++++++++++++++++++++++++
Minternal/gates/gates.go100++++++++++++-
A internal/gates/agents_test.go +245 -0
@@ -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+}
M internal/gates/gates.go +97 -3
@@ -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 }