clone

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

mirror agent grants on publish, add agent management methods and advertise active agents

Dami committed e69d192
commit
e69d1926bc0cc2376ed0f9c9455002ceae29f297
parent
3e13e858ec6f4c01975a4ad56ad678a51e73f5fe
author
Dami <dami.etoile@gmail.com>
6 files changed, +331 -4
Ainternal/daemon/agents_test.go257++++++++++++++++++++++++++++++
Minternal/daemon/capabilities.go3+
Minternal/daemon/http.go8+
Minternal/daemon/management.go49+++++-
Minternal/daemon/services.go1+
Minternal/daemon/tenant.go17++
A internal/daemon/agents_test.go +257 -0
@@ -0,0 +1,257 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strconv"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/community"14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/relay"16+)17+18+const (19+ testOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"20+ testAgentSecret = "0000000000000000000000000000000000000000000000000000000000000002"21+ testMemberSecret = "0000000000000000000000000000000000000000000000000000000000000003"22+ testModSecret = "0000000000000000000000000000000000000000000000000000000000000004"23+)24+25+func signedEvent(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event {26+ t.Helper()27+ if tags == nil {28+ tags = [][]string{}29+ }30+ e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content}31+ if err := event.Sign(&e, secret); err != nil {32+ t.Fatal(err)33+ }34+ return e35+}36+37+func agentGrantEvent(t *testing.T, agent string, createdAt, expires int64, extra ...[]string) event.Event {38+ t.Helper()39+ tags := [][]string{{"d", agent}, {"p", agent}, {"name", "helper"}, {"expiration", strconv.FormatInt(expires, 10)}}40+ return signedEvent(t, testOwnerSecret, event.KIND_AGENT_GRANT, createdAt, append(tags, extra...), "")41+}42+43+func publishAs(t *testing.T, tenant *Tenant, e event.Event) error {44+ t.Helper()45+ _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}})46+ return err47+}48+49+func TestAgentGrantPublishCreatesRoleRowAndRevokesOnDeletion(t *testing.T) {50+ app, tenant := testTenant(t)51+ ctx := context.Background()52+ agent, _ := event.PublicKey(testAgentSecret)53+ now := time.Now().Unix()54+ grant := agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})55+ if err := publishAs(t, tenant, grant); err != nil {56+ t.Fatalf("grant rejected: %v", err)57+ }58+ if role, _ := tenant.community.Role(ctx, agent); role != "agent" {59+ t.Fatalf("role = %q, want agent", role)60+ }61+ var owner, eventID, name string62+ var expires int6463+ if err := tenant.store.DB().QueryRow(`SELECT owner,event_id,name,expires_at FROM agent_grants WHERE agent=?`, agent).Scan(&owner, &eventID, &name, &expires); err != nil {64+ t.Fatal(err)65+ }66+ if owner != tenant.Policy().Owner || eventID != grant.ID || name != "helper" || expires != now+3600 {67+ t.Fatalf("grant row owner=%s event=%s name=%s expires=%d", owner, eventID, name, expires)68+ }69+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello")); err != nil {70+ t.Fatalf("agent note rejected: %v", err)71+ }72+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 7, now, nil, "+")); err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant does not allow kind 7") {73+ t.Fatalf("agent reaction: %v", err)74+ }75+ records, err := tenant.records.PublishMembership(ctx, now)76+ if err != nil {77+ t.Fatal(err)78+ }79+ labelled := map[int]bool{}80+ for _, e := range records {81+ for _, tag := range e.Tags {82+ if len(tag) == 3 && tag[1] == agent && tag[2] == "agent" && (e.Kind == event.KIND_ROSTER || e.Kind == event.KIND_GROUP_MEMBERS) {83+ labelled[e.Kind] = true84+ }85+ if e.Kind == event.KIND_ROLE_DEF && tag[0] == "d" && tag[1] == "agent" {86+ labelled[e.Kind] = true87+ }88+ }89+ }90+ if !labelled[event.KIND_ROSTER] || !labelled[event.KIND_GROUP_MEMBERS] || !labelled[event.KIND_ROLE_DEF] {91+ t.Fatalf("agent role missing from records: %v", labelled)92+ }93+ found := false94+ for _, capability := range tenant.Capabilities(nil) {95+ if capability.ID == "agents" && capability.Status == "enabled" {96+ found = true97+ }98+ }99+ if !found {100+ t.Fatal("agents capability not advertised")101+ }102+ req := httptest.NewRequest(http.MethodGet, "http://relay.test/", nil)103+ req.Header.Set("Accept", "application/nostr+json")104+ res := httptest.NewRecorder()105+ app.ServeHTTP(res, req)106+ var info map[string]json.RawMessage107+ if err := json.Unmarshal(res.Body.Bytes(), &info); err != nil || res.Code != 200 {108+ t.Fatalf("info %d %s %v", res.Code, res.Body.String(), err)109+ }110+ if !strings.Contains(string(info["agents"]), `"id":"agents"`) {111+ t.Fatalf("NIP-11 agents entry = %s", info["agents"])112+ }113+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+1, [][]string{{"a", "30392:" + tenant.Policy().Owner + ":" + agent}}, "")114+ if err := publishAs(t, tenant, deletion); err != nil {115+ t.Fatalf("deletion rejected: %v", err)116+ }117+ if role, _ := tenant.community.Role(ctx, agent); role != "" {118+ t.Fatalf("role after deletion = %q", role)119+ }120+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "again")); err == nil || !strings.Contains(err.Error(), "revoked") {121+ t.Fatalf("revoked agent note: %v", err)122+ }123+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+3, now+3600, []string{"k", "1"})); err != nil {124+ t.Fatalf("new grant rejected: %v", err)125+ }126+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "back")); err != nil {127+ t.Fatalf("re-granted agent rejected: %v", err)128+ }129+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+5, now-1, []string{"k", "1"})); err != nil {130+ t.Fatalf("expired grant rejected: %v", err)131+ }132+ if role, _ := tenant.community.Role(ctx, agent); role != "" {133+ t.Fatalf("role after expired grant = %q", role)134+ }135+ var revoked int64136+ if err := tenant.store.DB().QueryRow(`SELECT revoked_at FROM agent_grants WHERE agent=?`, agent).Scan(&revoked); err != nil || revoked == 0 {137+ t.Fatalf("expired grant not revoked: %d %v", revoked, err)138+ }139+ if found := capabilityIDs(tenant)["agents"]; found {140+ t.Fatal("agents capability advertised without an active agent")141+ }142+}143+144+func capabilityIDs(tenant *Tenant) map[string]bool {145+ out := map[string]bool{}146+ for _, capability := range tenant.Capabilities(nil) {147+ out[capability.ID] = true148+ }149+ return out150+}151+152+func TestAgentManagementMethodsAndPermissions(t *testing.T) {153+ _, tenant := testTenant(t)154+ ctx := context.Background()155+ owner := tenant.Policy().Owner156+ agent, _ := event.PublicKey(testAgentSecret)157+ member, _ := event.PublicKey(testMemberSecret)158+ moderator, _ := event.PublicKey(testModSecret)159+ now := time.Now().Unix()160+ for _, target := range []struct {161+ pubkey, role string162+ }{{member, "member"}, {moderator, "moderator"}} {163+ raw, _ := json.Marshal(map[string]string{"role": target.role})164+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(target.pubkey)), raw}); err != nil {165+ t.Fatal(err)166+ }167+ }168+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil {169+ t.Fatal(err)170+ }171+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)172+ if err != nil {173+ t.Fatal(err)174+ }175+ for _, name := range []string{"listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {176+ if !containsString(methods.([]string), name) {177+ t.Fatalf("supportedmethods lacks %s", name)178+ }179+ }180+ param := []json.RawMessage{json.RawMessage(strconv.Quote(agent))}181+ for _, actor := range []string{member, agent, strings.Repeat("e", 64)} {182+ for _, method := range []string{"listagents", "pauseagent", "revokeagent", "pauseallagents"} {183+ if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {184+ t.Fatalf("%s by non-admin: %v", method, err)185+ }186+ }187+ }188+ listed, err := tenant.Execute(ctx, moderator, "listagents", nil)189+ if err != nil {190+ t.Fatalf("moderator listagents: %v", err)191+ }192+ agents := listed.([]community.AgentSummary)193+ if len(agents) != 1 || agents[0].Agent != agent || agents[0].Name != "helper" || agents[0].Owner != owner || agents[0].Paused || agents[0].RevokedAt != 0 || agents[0].Scope.Kinds[0] != 1 {194+ t.Fatalf("listagents = %+v", agents)195+ }196+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello")); err != nil {197+ t.Fatal(err)198+ }199+ listed, _ = tenant.Execute(ctx, owner, "listagents", nil)200+ if last := listed.([]community.AgentSummary)[0].LastEvent; last != now {201+ t.Fatalf("lastEvent = %d, want %d", last, now)202+ }203+ if _, err := tenant.Execute(ctx, moderator, "pauseagent", param); err != nil {204+ t.Fatal(err)205+ }206+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+1, nil, "paused")); err == nil || !strings.Contains(err.Error(), "paused") {207+ t.Fatalf("paused agent note: %v", err)208+ }209+ if _, err := tenant.Execute(ctx, owner, "resumeagent", param); err != nil {210+ t.Fatal(err)211+ }212+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "resumed")); err != nil {213+ t.Fatalf("resumed agent note: %v", err)214+ }215+ result, err := tenant.Execute(ctx, owner, "pauseallagents", nil)216+ if err != nil || result.(map[string]any)["changed"] != 1 {217+ t.Fatalf("pauseallagents = %v %v", result, err)218+ }219+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+3, nil, "all paused")); err == nil || !strings.Contains(err.Error(), "paused") {220+ t.Fatalf("note during pause: %v", err)221+ }222+ if result, err := tenant.Execute(ctx, owner, "resumeallagents", nil); err != nil || result.(map[string]any)["changed"] != 1 {223+ t.Fatalf("resumeallagents = %v %v", result, err)224+ }225+ if _, err := tenant.Execute(ctx, owner, "revokeagent", param); err != nil {226+ t.Fatal(err)227+ }228+ if role, _ := tenant.community.Role(ctx, agent); role != "" {229+ t.Fatalf("role after revoke = %q", role)230+ }231+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "revoked")); err == nil || !strings.Contains(err.Error(), "revoked") {232+ t.Fatalf("revoked agent note: %v", err)233+ }234+ var stored int235+ if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=? AND d=?`, event.KIND_AGENT_GRANT, agent).Scan(&stored); err != nil || stored != 1 {236+ t.Fatalf("grant event kept for audit: %d %v", stored, err)237+ }238+ if _, err := tenant.Execute(ctx, owner, "pauseagent", param); err == nil {239+ t.Fatal("paused a revoked agent")240+ }241+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(strings.Repeat("f", 64)))}); err == nil {242+ t.Fatal("revoked an unknown agent")243+ }244+ audit, err := tenant.Execute(ctx, owner, "listaudit", nil)245+ if err != nil {246+ t.Fatal(err)247+ }248+ seen := map[string]bool{}249+ for _, row := range audit.([]community.AuditRow) {250+ seen[row.Action] = true251+ }252+ for _, action := range []string{"grantagent", "pauseagent", "resumeagent", "pauseallagents", "resumeallagents", "revokeagent"} {253+ if !seen[action] {254+ t.Fatalf("audit lacks %s: %v", action, seen)255+ }256+ }257+}
M internal/daemon/capabilities.go +3 -0
M internal/daemon/http.go +8 -0
M internal/daemon/management.go +45 -4
@@ -19,6 +19,7 @@ import (
1919 "time"2020 2121 "github.com/FelineStateMachine/tinyrelay/internal/catalog"22+ "github.com/FelineStateMachine/tinyrelay/internal/community"2223 "github.com/FelineStateMachine/tinyrelay/internal/domains"2324 "github.com/FelineStateMachine/tinyrelay/internal/event"2425 "github.com/FelineStateMachine/tinyrelay/internal/policy"
@@ -88,6 +89,8 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
8889 return t.identity(), true, nil8990 case "transferowner":9091 return t.transferOwner(ctx, actor, params)92+ case "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents":93+ return t.agentExecute(ctx, actor, method, params)9194 case "forkrelay":9295 return t.forkRelay(ctx, actor, params)9396 case "deleterelay":
@@ -98,7 +101,7 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
98101 }99102 100103 func managementAdapterMethod(method string) bool {101- for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay"} {104+ for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {102105 if method == name {103106 return true104107 }
@@ -125,7 +128,7 @@ func publicManagementMethod(method string) bool {
125128 // ManagementMethods is the source-compatible NIP-86 registry. The hosted126129 // lease, trial, fuel, and entitlement controls are intentionally absent.127130 func ManagementMethods() []string {128- return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains"}131+ return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"}129132 }130133 131134 func managementRoleAllows(method, role string) bool {
@@ -135,7 +138,7 @@ func managementRoleAllows(method, role string) bool {
135138 if role != "moderator" {136139 return false137140 }138- for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent"} {141+ for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {139142 if method == name {140143 return true141144 }
@@ -276,6 +279,44 @@ func (t *Tenant) transferOwner(ctx context.Context, actor string, params []json.
276279 return map[string]any{"owner": next, "previousOwner": current}, true, nil277280 }278281 282+// agentExecute serves the agent kill switch and inventory. The community283+// service records each change in the audit log; this adapter only shapes284+// parameters and logs counts.285+func (t *Tenant) agentExecute(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) {286+ now := time.Now().Unix()287+ switch method {288+ case "listagents":289+ agents, err := t.community.Agents(ctx)290+ return agents, true, err291+ case "pauseallagents", "resumeallagents":292+ changed, err := t.community.SetAllAgentsPaused(ctx, actor, method == "pauseallagents")293+ if err != nil {294+ return nil, true, err295+ }296+ t.app.telemetry.Logger().Info("agent grants changed", "tenant", t.meta.Name, "action", method, "changed", changed)297+ return map[string]any{"changed": changed, "paused": method == "pauseallagents"}, true, nil298+ }299+ agent := stringParam(params, 0)300+ if len(agent) != 64 {301+ return nil, true, errors.New("invalid: agent public key required")302+ }303+ if _, err := hex.DecodeString(agent); err != nil {304+ return nil, true, errors.New("invalid: agent public key required")305+ }306+ var grant community.AgentGrant307+ var err error308+ switch method {309+ case "pauseagent", "resumeagent":310+ grant, err = t.community.SetAgentPaused(ctx, actor, agent, method == "pauseagent")311+ case "revokeagent":312+ grant, err = t.community.RevokeAgent(ctx, actor, agent, now)313+ }314+ if err != nil {315+ return nil, true, err316+ }317+ return grant, true, nil318+}319+279320 func (t *Tenant) deleteRelay(ctx context.Context, params []json.RawMessage) (any, bool, error) {280321 name := stringParam(params, 0)281322 if name == "" || !strings.EqualFold(name, t.meta.Name) {
@@ -403,7 +444,7 @@ func copyForkConfig(ctx context.Context, source, target *storage.Store) error {
403444 }404445 405446 func copyForkPeople(ctx context.Context, source, target *storage.Store, owner string) error {406- rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner'`)447+ rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner' AND role<>'agent'`)407448 if err != nil {408449 return fmt.Errorf("fork members: %w", err)409450 }
M internal/daemon/services.go +1 -0
M internal/daemon/tenant.go +17 -0