diff --git a/.config/mise/config.toml b/.config/mise/config.toml index 9992e78..a41b513 100644 --- a/.config/mise/config.toml +++ b/.config/mise/config.toml @@ -10,3 +10,6 @@ auto = true [tasks.dev] run = "go run ./cmd/meshtender" + +[tasks.reset] +run = "go run ./cmd/meshtender --reset" diff --git a/cmd/meshtender/main.go b/cmd/meshtender/main.go index 18e75ff..c315dbd 100644 --- a/cmd/meshtender/main.go +++ b/cmd/meshtender/main.go @@ -4,6 +4,7 @@ package main import ( "context" "errors" + "flag" "log/slog" "net/http" "os" @@ -27,6 +28,11 @@ func main() { } func run(logger *slog.Logger) error { + var reset bool + flag.BoolVar(&reset, "reset", false, + "truncate all data except users, passkeys, sessions, and the server identity, then exit") + flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -46,6 +52,14 @@ func run(logger *slog.Logger) error { } logger.Info("migrations applied") + if reset { + if err := st.Reset(ctx); err != nil { + return err + } + logger.Info("database reset — kept users, passkeys, sessions, and the server identity") + return nil + } + idSvc, err := identity.LoadOrCreate(ctx, st, cfg.MasterKey) if err != nil { return err diff --git a/internal/core/command_features.go b/internal/core/command_features.go index acdeee7..22e11bf 100644 --- a/internal/core/command_features.go +++ b/internal/core/command_features.go @@ -37,7 +37,5 @@ func orderFeatures(present []string) { }) } -// The feature×operation table builder (FeatureTableFor) lives in internal/web so -// both the app host and the root host can build it for the read-only consent / -// requested-access views; this file keeps only the feature ordering, still used -// by groupByFeature for the catalog/editor groupings. +// This file keeps the feature ordering used by groupByFeature for the catalog and +// share/org command-selection groupings. diff --git a/internal/core/contribute.go b/internal/core/contribute.go deleted file mode 100644 index ee24db4..0000000 --- a/internal/core/contribute.go +++ /dev/null @@ -1,200 +0,0 @@ -package core - -import ( - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/jleight/meshtender/internal/store" - "github.com/jleight/meshtender/internal/web" -) - -// orgContext resolves the {id} repeater (owned) and {orgID} the user belongs to. -func (s *Handlers) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { - owner := s.Auth.CurrentUserID(r.Context()) - id, ok := s.repeaterID(r) - orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID")) - if !ok || oerr != nil { - http.NotFound(w, r) - return nil, 0, false - } - rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id) - if err != nil { - http.NotFound(w, r) - return nil, 0, false - } - if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember { - http.NotFound(w, r) // can only contribute to orgs you belong to - return nil, 0, false - } - return rep, orgID, true -} - -// pageContribute shows the org's current permission envelope for the owner to -// review before consenting (also used for re-consent). -func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) { - rep, orgID, ok := s.orgContext(w, r) - if !ok { - return - } - org, err := s.Store.GetOrg(r.Context(), orgID) - if err != nil { - http.NotFound(w, r) - return - } - versionID, version, err := s.Store.CurrentVersion(r.Context(), orgID) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - catalog, err := s.Store.ListCommands(r.Context()) - if err != nil { - http.Error(w, "could not load commands", http.StatusInternalServerError) - return - } - members := idSet(memberIDs) - data := map[string]any{ - "Repeater": rep, - "Org": org, - "Version": version, - "MemberFeatures": web.FeatureTableFor(catalog, members), - // Admins inherit every member command, so their table is member ∪ admin. - "AdminFeatures": web.FeatureTableFor(catalog, union(idSet(adminIDs), members)), - } - - // If already contributed and behind the current version, show what changed - // since the owner last consented. - cvID, contributed, _ := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID) - if contributed && cvID != versionID { - cAdmin, cMember, err1 := s.Store.VersionCommandIDs(r.Context(), cvID) - consentedNum, err2 := s.Store.VersionNumber(r.Context(), cvID) - if err1 == nil && err2 == nil { - tmpl := map[int64]string{} - for _, c := range catalog { - tmpl[c.ID] = c.Template - } - consented := union(idSet(cAdmin), idSet(cMember)) - current := union(idSet(adminIDs), idSet(memberIDs)) - data["Reconsent"] = true - data["ConsentedVersion"] = consentedNum - data["Added"] = templatesFor(current, consented, tmpl) // newly granted - data["Removed"] = templatesFor(consented, current, tmpl) // no longer granted - if notes, err := s.Store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil { - data["Notes"] = notes - } - } - } - - s.Render(w, r, "contribute.html", data) -} - -// pageConsented shows, read-only, the exact commands this repeater is currently -// consented to grant the org — the detail behind "consented to vN" on the -// sharing page. It renders the consented version, which may lag the org's -// current one. -func (s *Handlers) pageConsented(w http.ResponseWriter, r *http.Request) { - rep, orgID, ok := s.orgContext(w, r) - if !ok { - return - } - org, err := s.Store.GetOrg(r.Context(), orgID) - if err != nil { - http.NotFound(w, r) - return - } - cvID, contributed, err := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID) - if err != nil || !contributed { - http.NotFound(w, r) // not contributed → nothing consented to view - return - } - version, err := s.Store.VersionNumber(r.Context(), cvID) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), cvID) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - catalog, err := s.Store.ListCommands(r.Context()) - if err != nil { - http.Error(w, "could not load commands", http.StatusInternalServerError) - return - } - members := idSet(memberIDs) - data := map[string]any{ - "Repeater": rep, - "Org": org, - "Version": version, - "MemberFeatures": web.FeatureTableFor(catalog, members), - "AdminFeatures": web.FeatureTableFor(catalog, union(idSet(adminIDs), members)), - } - // Note (with a re-consent link) when the org has moved past this version. - if _, current, err := s.Store.CurrentVersion(r.Context(), orgID); err == nil && current > version { - data["CurrentVersion"] = current - } - s.Render(w, r, "consented.html", data) -} - -// union returns the set union of two id sets. -func union(a, b map[int64]bool) map[int64]bool { - out := make(map[int64]bool, len(a)+len(b)) - for id := range a { - out[id] = true - } - for id := range b { - out[id] = true - } - return out -} - -// templatesFor returns the command templates for ids in `in` but not in `notIn`. -func templatesFor(in, notIn map[int64]bool, tmpl map[int64]string) []string { - var out []string - for id := range in { - if !notIn[id] { - if t := tmpl[id]; t != "" { - out = append(out, t) - } - } - } - return out -} - -// handleContribute pins the repeater to the org's current permission version. -func (s *Handlers) handleContribute(w http.ResponseWriter, r *http.Request) { - rep, orgID, ok := s.orgContext(w, r) - if !ok { - return - } - versionID, _, err := s.Store.CurrentVersion(r.Context(), orgID) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - owner := s.Auth.CurrentUserID(r.Context()) - if err := s.Store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil { - http.Error(w, "could not contribute", http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/share", http.StatusSeeOther) -} - -// handleWithdraw removes the repeater from the org. -func (s *Handlers) handleWithdraw(w http.ResponseWriter, r *http.Request) { - rep, orgID, ok := s.orgContext(w, r) - if !ok { - return - } - if err := s.Store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil { - http.Error(w, "could not withdraw", http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/share", http.StatusSeeOther) -} diff --git a/internal/core/org_participation.go b/internal/core/org_participation.go new file mode 100644 index 0000000..8d39822 --- /dev/null +++ b/internal/core/org_participation.go @@ -0,0 +1,165 @@ +package core + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/jleight/meshtender/internal/store" +) + +// repeaterOrgContext resolves the {id} repeater (must be owned by the caller) and +// the {orgID} org slug (the caller must belong to it). It's the gate for the +// per-org participation toggle a repeater owner controls. +func (s *Handlers) repeaterOrgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { + owner := s.Auth.CurrentUserID(r.Context()) + id, ok := s.repeaterID(r) + orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID")) + if !ok || oerr != nil { + http.NotFound(w, r) + return nil, 0, false + } + rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id) + if err != nil { + http.NotFound(w, r) + return nil, 0, false + } + if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember { + http.NotFound(w, r) // can only manage participation in orgs you belong to + return nil, 0, false + } + return rep, orgID, true +} + +// handleSetRepeaterOrg opts a repeater into or out of an org (owner only). A +// repeater participates in every org its owner belongs to by default; this writes +// or clears the opt-out. +func (s *Handlers) handleSetRepeaterOrg(w http.ResponseWriter, r *http.Request) { + rep, orgID, ok := s.repeaterOrgContext(w, r) + if !ok { + return + } + exclude := r.FormValue("action") == "exclude" + if err := s.Store.SetRepeaterOrgExcluded(r.Context(), orgID, rep.ID, exclude); err != nil { + http.Error(w, "could not update participation", http.StatusInternalServerError) + return + } + http.Redirect(w, r, sharePath(rep.PublicID), http.StatusSeeOther) +} + +// pageOrgCommands lets a member restrict, for one org, which of the commands that +// org is permitted to run actually run on the member's repeaters. No restriction +// (the default) means every command in the org's ceiling can run. +func (s *Handlers) pageOrgCommands(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + if _, isMember, err := s.Store.OrgRole(r.Context(), id, uid); err != nil || !isMember { + http.NotFound(w, r) + return + } + ceiling, err := s.orgCeilingCommands(r) + if err != nil { + http.Error(w, "could not load commands", http.StatusInternalServerError) + return + } + optIn, _ := s.Store.OrgOptInCommandIDs(r.Context(), id, uid) + restricted := len(optIn) > 0 + // Permissive (no list) shows everything checked: all ceiling commands may run. + checked := make(map[int64]bool, len(ceiling)) + if restricted { + for _, cid := range optIn { + checked[cid] = true + } + } else { + for _, c := range ceiling { + checked[c.ID] = true + } + } + s.Render(w, r, "org_commands.html", map[string]any{ + "Org": org, + "Groups": groupCommands(ceiling, checked), + "Restricted": restricted, + }) +} + +// handleSaveOrgCommands saves the member's per-org opt-in list. If every ceiling +// command is selected (or none of the modes restrict), the list is cleared back to +// permissive so we don't persist a redundant full allowlist. +func (s *Handlers) handleSaveOrgCommands(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + if _, isMember, err := s.Store.OrgRole(r.Context(), id, uid); err != nil || !isMember { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + // "Remove restriction" clears the list regardless of checkboxes. + if r.FormValue("clear") != "" { + if err := s.Store.SetOrgOptIn(r.Context(), id, uid, nil); err != nil { + http.Error(w, "could not save", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther) + return + } + ceiling, err := s.orgCeilingCommands(r) + if err != nil { + http.Error(w, "could not load commands", http.StatusInternalServerError) + return + } + chosen := parseCommandIDs(r.Form["cmd"]) + // Selecting the full ceiling is equivalent to permissive — store nothing. + if len(chosen) >= len(ceiling) { + chosen = nil + } + if err := s.Store.SetOrgOptIn(r.Context(), id, uid, chosen); err != nil { + http.Error(w, "could not save", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther) +} + +// orgCeilingCommands returns the catalog commands an org is ever permitted to run +// (member or admin tier) — the universe the opt-in editor restricts within. +func (s *Handlers) orgCeilingCommands(r *http.Request) ([]*store.Command, error) { + catalog, err := s.Store.ListCommands(r.Context()) + if err != nil { + return nil, err + } + var out []*store.Command + for _, c := range catalog { + if c.OrgMemberAllowed || c.OrgAdminAllowed { + out = append(out, c) + } + } + return out, nil +} + +// parseCommandIDs parses a slice of form values into catalog command ids, skipping +// any that aren't valid integers. +func parseCommandIDs(values []string) []int64 { + var ids []int64 + for _, v := range values { + if cid, err := strconv.ParseInt(v, 10, 64); err == nil { + ids = append(ids, cid) + } + } + return ids +} diff --git a/internal/core/org_permissions.go b/internal/core/org_permissions.go deleted file mode 100644 index 28e6a2e..0000000 --- a/internal/core/org_permissions.go +++ /dev/null @@ -1,133 +0,0 @@ -package core - -import ( - "net/http" - "strconv" - - "github.com/jleight/meshtender/internal/store" - "github.com/jleight/meshtender/internal/web" -) - -// permGroup is a category of catalog commands for the org permission editor, -// with per-tier checkbox state. -type permGroup = categoryGroup[permChoice] - -type permChoice struct { - ID int64 - Template string - Args string - Risky bool - AdminChecked bool - MemberChecked bool -} - -func groupPermissions(catalog []*store.Command, admin, member map[int64]bool) []permGroup { - return groupByFeature(catalog, func(c *store.Command) permChoice { - return permChoice{ - ID: c.ID, Template: c.Template, Args: c.Args, Risky: c.Risky, - AdminChecked: admin[c.ID], MemberChecked: member[c.ID], - } - }) -} - -func idSet(ids []int64) map[int64]bool { - m := make(map[int64]bool, len(ids)) - for _, id := range ids { - m[id] = true - } - return m -} - -// pageOrgPermissions renders the org's requested-access policy read-only. Visible -// to any signed-in user (so prospective members can see what they'd consent to); -// admins get an Edit button. The root host serves the same page anonymously. -func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { - uid := s.Auth.CurrentUserID(r.Context()) - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return - } - org, err := s.Store.GetOrg(r.Context(), id) - if err != nil { - http.NotFound(w, r) - return - } - role, isMember, err := s.Store.OrgRole(r.Context(), id, uid) - if err != nil { - http.Error(w, "could not load org", http.StatusInternalServerError) - return - } - pv, err := web.BuildPermissionsView(r.Context(), s.Store, id) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - s.Render(w, r, "org_permissions.html", map[string]any{ - "Org": org, - "Nav": web.OrgNav(org.Slug, "permissions", isMember), - "CanEdit": role == "admin", - "Perms": pv, - }) -} - -// pageOrgPermissionsEdit is the admin editor for the org's requested-access policy. -func (s *Handlers) pageOrgPermissionsEdit(w http.ResponseWriter, r *http.Request) { - id, ok := s.requireOrgAdmin(w, r) - if !ok { - return - } - org, err := s.Store.GetOrg(r.Context(), id) - if err != nil { - http.NotFound(w, r) - return - } - versionID, version, err := s.Store.CurrentVersion(r.Context(), id) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - catalog, err := s.Store.ListCommands(r.Context()) - if err != nil { - http.Error(w, "could not load commands", http.StatusInternalServerError) - return - } - s.Render(w, r, "permissions_edit.html", map[string]any{ - "Org": org, - "Nav": web.OrgNav(org.Slug, "permissions", true), - "Version": version, - "Groups": groupPermissions(catalog, idSet(adminIDs), idSet(memberIDs)), - }) -} - -func (s *Handlers) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) { - id, ok := s.requireOrgAdmin(w, r) - if !ok { - return - } - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) - return - } - parse := func(field string) []int64 { - var ids []int64 - for _, v := range r.Form[field] { - if cid, err := strconv.ParseInt(v, 10, 64); err == nil { - ids = append(ids, cid) - } - } - return ids - } - uid := s.Auth.CurrentUserID(r.Context()) - note := r.FormValue("note") - if _, err := s.Store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil { - http.Error(w, "could not publish", http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/orgs/"+orgParam(r)+"/permissions", http.StatusSeeOther) -} diff --git a/internal/core/shares.go b/internal/core/shares.go index 5497168..0f30188 100644 --- a/internal/core/shares.go +++ b/internal/core/shares.go @@ -16,7 +16,6 @@ import ( // pageShare renders the sharing page for a repeater the user owns: the current // share link (if any) and the list of people who have accepted. func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) { - uid := s.Auth.CurrentUserID(r.Context()) rep, id, ok := s.requireRepeaterOwned(w, r) if !ok { return @@ -31,36 +30,20 @@ func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load links", http.StatusInternalServerError) return } - // Organizations section: orgs this repeater is contributed to, plus orgs the - // owner belongs to but hasn't contributed it to yet. - contributed, err := s.Store.ListRepeaterOrgs(r.Context(), id) + // Organizations section: every org the owner belongs to, with whether this + // repeater participates (the default) or has been opted out. + orgs, err := s.Store.ListRepeaterOrgMemberships(r.Context(), id) if err != nil { http.Error(w, "could not load orgs", http.StatusInternalServerError) return } - memberships, err := s.Store.ListOrgsForUser(r.Context(), uid) - if err != nil { - http.Error(w, "could not load memberships", http.StatusInternalServerError) - return - } - in := map[int64]bool{} - for _, c := range contributed { - in[c.OrgID] = true - } - var available []*store.Org - for _, m := range memberships { - if !in[m.Org.ID] { - available = append(available, m.Org) - } - } s.Render(w, r, "share.html", map[string]any{ - "Repeater": rep, - "Shares": shares, - "Invites": invites, - "Contributed": contributed, - "Available": available, - "BaseURL": s.absoluteURL(r, ""), - "Error": r.URL.Query().Get("error"), + "Repeater": rep, + "Shares": shares, + "Invites": invites, + "Orgs": orgs, + "BaseURL": s.absoluteURL(r, ""), + "Error": r.URL.Query().Get("error"), }) } diff --git a/internal/core/templates/admin_catalog.html b/internal/core/templates/admin_catalog.html index 2a4b1fe..6dc00ab 100644 --- a/internal/core/templates/admin_catalog.html +++ b/internal/core/templates/admin_catalog.html @@ -11,10 +11,11 @@

- The firmware commands MeshTender can send. A repeater owner can run anything; these flags seed what - others are offered — share = default for a new one-off share, member/admin - = the org default tiers. Risky commands can lock the owner out or brick a node. Hover a - command to see what it does. + The firmware commands MeshTender can send. A repeater owner can run anything. Share seeds + the default command set for a new one-off share. Member/admin are the + ceiling of what an organization may ever run on a contributed repeater, by tier. Risky + commands can lock the owner out or brick a node — leave these out of the org tiers. Hover a command to + see what it does.

{{if .Saved}}
Saved
{{end}}
@@ -41,8 +42,8 @@ {{.Template}} - - + +
{{end}} diff --git a/internal/core/templates/consented.html b/internal/core/templates/consented.html deleted file mode 100644 index 28a331d..0000000 --- a/internal/core/templates/consented.html +++ /dev/null @@ -1,43 +0,0 @@ -{{define "title"}}{{.Repeater.Name}} · {{.Org.Name}} commands · MeshTender{{end}} -{{define "header"}} -
-
-
Consented to {{.Org.Name}} · v{{.Version}}
-

{{.Repeater.Name}}

-
-
-{{end}} -{{define "content"}} -
-
-

- These are the commands {{.Org.Name}}'s admins and members can run on this repeater over the mesh — the - policy you consented to (v{{.Version}}). Hover a command to see what it does. -

- {{if .CurrentVersion}} -
- {{.Org.Name}} has since published v{{.CurrentVersion}}. This repeater stays on v{{.Version}} until you - review the changes and re-consent. -
- {{end}} -
-
- -
-

Members can run

-
- {{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}} - {{else}}

Members aren't granted any commands.

{{end}} -
-
- -
-

Admins can run

-
- {{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}} - {{else}}

Admins aren't granted any commands.

{{end}} -
-
- -{{template "icon-arrow-left" "me-1"}}Back to sharing -{{end}} diff --git a/internal/core/templates/contribute.html b/internal/core/templates/contribute.html deleted file mode 100644 index 15453cd..0000000 --- a/internal/core/templates/contribute.html +++ /dev/null @@ -1,56 +0,0 @@ -{{define "title"}}Contribute {{.Repeater.Name}} · MeshTender{{end}} -{{define "header"}} -
-
-
Contribute to {{.Org.Name}}
-

{{.Repeater.Name}}

-
-
-{{end}} -{{define "content"}} -
-
-

- By consenting, you allow {{.Org.Name}}'s admins and members to run these commands on this repeater - over the mesh (policy v{{.Version}}). If the org later - adds commands, this repeater stays on v{{.Version}} until you review and re-consent. You can - withdraw anytime. Hover a command to see what it does. -

- - {{if .Reconsent}} -
-

Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}})

- {{if .Added}}
Newly granted: {{range .Added}}{{.}} {{end}}
{{end}} - {{if .Removed}}
No longer granted: {{range .Removed}}{{.}} {{end}}
{{end}} - {{if not (or .Added .Removed)}}
No command changes (notes/metadata only).
{{end}} - {{if .Notes}}
- {{range .Notes}}v{{.Version}}{{if .Note}}: {{.Note}}{{end}}
{{end}} -
{{end}} -
- {{end}} -
-
- -
-

Members can run

-
- {{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}} - {{else}}

Members aren't granted any commands.

{{end}} -
-
- -
-

Admins can run

-
- {{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}} - {{else}}

Admins aren't granted any commands.

{{end}} -
-
- -
-
- - Cancel -
-
-{{end}} diff --git a/internal/core/templates/dashboard.html b/internal/core/templates/dashboard.html index 845b34b..152468c 100644 --- a/internal/core/templates/dashboard.html +++ b/internal/core/templates/dashboard.html @@ -9,13 +9,6 @@ {{end}} {{define "content"}} {{if .Error}}{{end}} -{{if .Reconsent}} - -{{end}}
diff --git a/internal/core/templates/org_commands.html b/internal/core/templates/org_commands.html new file mode 100644 index 0000000..4bec179 --- /dev/null +++ b/internal/core/templates/org_commands.html @@ -0,0 +1,52 @@ +{{define "title"}}Limit commands · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}}
+

Limit commands

+
+
+{{end}} +{{define "content"}} +
+
+

+ Restrict which of the commands {{.Org.Name}} is permitted to run actually run on + your repeaters shared with it. This applies to every repeater you share with this organization. + {{if .Restricted}} + You're currently allowing only the checked commands. + {{else}} + No restriction is set — every command the organization is permitted to run can run. Uncheck commands to + restrict them. + {{end}} +

+
+
+ +
+ {{range .Groups}} +
+

{{.Name}}

+
+ {{range .Commands}} + + {{end}} +
+
+ {{end}} +
+ + {{if .Restricted}} + + {{end}} + Cancel +
+
+{{end}} diff --git a/internal/core/templates/permissions_edit.html b/internal/core/templates/permissions_edit.html deleted file mode 100644 index 18bb5fb..0000000 --- a/internal/core/templates/permissions_edit.html +++ /dev/null @@ -1,55 +0,0 @@ -{{define "title"}}Edit requested access · {{.Org.Name}} · MeshTender{{end}} -{{define "header"}} -
-
-
{{.Org.Name}}
-

Edit requested access

-
- -
-{{end}} -{{define "content"}} -{{template "org-tabs" .Nav}} - -
-
-

- Current version: v{{.Version}}. Choose which commands org admins - and members may run on contributed repeaters. Saving publishes a new version; - members' repeaters stay on their consented version until owners re-consent to the additions. - Admins effectively also get everything members get. Risky commands can take over or brick a node. -

-
-
- -
- {{range .Groups}} -
-

{{.Name}}

-
- {{range .Commands}} -
- {{.Template}}{{if .Risky}} risky{{end}} - - -
- {{end}} -
-
- {{end}} -
-
-
- - -
-
- - Cancel -
-
-
-
-{{end}} diff --git a/internal/core/templates/repeater.html b/internal/core/templates/repeater.html index 69796c7..5748547 100644 --- a/internal/core/templates/repeater.html +++ b/internal/core/templates/repeater.html @@ -93,17 +93,16 @@ {{template "icon-world" ""}}
{{.OrgName}} -
consented to v{{.ConsentedVersion}}{{if gt .CurrentVersion .ConsentedVersion}} re-consent{{end}}
{{end}}
{{else}} -

Not contributed to any organization.

+

Not shared with any organization.

{{end}} diff --git a/internal/core/templates/repeater_added.html b/internal/core/templates/repeater_added.html index 5df026a..382abe4 100644 --- a/internal/core/templates/repeater_added.html +++ b/internal/core/templates/repeater_added.html @@ -43,19 +43,22 @@
-

Step 4 · Contribute to an organization (optional)

+

Step 4 · Organizations (optional)

{{if .Orgs}}

- Let an organization's admins and members help operate this repeater. You'll be asked to review and - consent to the list of commands that organization members and admins will be able to run on your - repeater. You can withdraw anytime. + This repeater is now shared with the organizations you belong to, so their admins and members can run + the commands those organizations are permitted to run on it. Opt out of any of them here — you can + change this anytime, and limit which commands an org may run, from the sharing page.

{{range .Orgs}}
{{.Org.Name}} {{if eq .Role "admin"}}admin{{else}}member{{end}} - Review & contribute +
+ + +
{{end}}
diff --git a/internal/core/templates/repeaters.html b/internal/core/templates/repeaters.html index 816d111..e490b2f 100644 --- a/internal/core/templates/repeaters.html +++ b/internal/core/templates/repeaters.html @@ -25,7 +25,6 @@

{{.Name}}

{{template "repstatus" .}} - {{if index $.Reconsent .ID}}re-consent needed{{end}}
{{slice .PublicKeyHex 0 8}}… diff --git a/internal/core/templates/share.html b/internal/core/templates/share.html index f666cc3..44806ad 100644 --- a/internal/core/templates/share.html +++ b/internal/core/templates/share.html @@ -23,44 +23,36 @@

Organizations

- Contribute this repeater to an organization so its admins and members can run their permitted - commands on it over the mesh — but only the specific commands you review and approve first. You can - withdraw anytime. + This repeater is shared with every organization you belong to, so their admins and members can run + the commands those organizations are permitted to run on it. Opt it out of any organization here, or + limit which commands an organization may run from its Limit commands page.

-
Contributed to
- {{if .Contributed}} + {{if .Orgs}}
- {{range .Contributed}} + {{range .Orgs}}
{{.OrgName}} - consented to v{{.ConsentedVersion}}{{if .NeedsReconsent}} · org is now on v{{.CurrentVersion}}{{end}} - {{if .NeedsReconsent}} - Review changes + {{if .Excluded}} + Opted out + {{else}} + Shared + Limit commands {{end}} -
- + + {{if .Excluded}} + + + {{else}} + + + {{end}}
{{end}}
{{else}} -

Not contributed to any organization.

- {{end}} - - {{if .Available}} -
Contribute to
-
- {{range .Available}} -
- {{.Name}} - Review & contribute -
- {{end}} -
- {{else if .Contributed}} - {{else}} -

Join an organization to contribute this repeater to it.

+

Join an organization to share this repeater with it.

{{end}}
diff --git a/internal/core/web.go b/internal/core/web.go index 2be387d..8c1eefd 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -142,10 +142,7 @@ func (s *Handlers) appRouter() chi.Router { r.Post("/repeaters/{id}/unshare", s.handleUnshare) r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands) r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands) - r.Get("/repeaters/{id}/orgs/{orgID}/consented", s.pageConsented) - r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute) - r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute) - r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw) + r.Post("/repeaters/{id}/orgs/{orgID}/participation", s.handleSetRepeaterOrg) r.Post("/invite/{token}/accept", s.handleAcceptInvite) r.Get("/orgs/new", s.pageNewOrg) @@ -154,9 +151,8 @@ func (s *Handlers) appRouter() chi.Router { r.Post("/orgs/{id}/join", s.handleJoinOrg) r.Post("/orgs/{id}/leave", s.handleLeaveOrg) r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember) - r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) - r.Get("/orgs/{id}/permissions/edit", s.pageOrgPermissionsEdit) - r.Post("/orgs/{id}/permissions/edit", s.handleSaveOrgPermissions) + r.Get("/orgs/{id}/my-commands", s.pageOrgCommands) + r.Post("/orgs/{id}/my-commands", s.handleSaveOrgCommands) r.Get("/orgs/{id}/config", s.pageOrgConfig) r.Get("/orgs/{id}/config/edit", s.pageOrgConfigEdit) r.Post("/orgs/{id}/config/edit", s.handleSaveOrgConfig) @@ -199,11 +195,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load repeaters", http.StatusInternalServerError) return } - reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(r.Context(), uid) - if err != nil { - http.Error(w, "could not load org state", http.StatusInternalServerError) - return - } shareCounts, err := s.Store.RepeaterSharingCounts(r.Context(), uid) if err != nil { http.Error(w, "could not load sharing", http.StatusInternalServerError) @@ -213,7 +204,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) { s.Render(w, r, "repeaters.html", map[string]any{ "Owned": owned, "Shared": shared, - "Reconsent": reconsent, "ShareCounts": shareCounts, "Error": r.URL.Query().Get("error"), }) @@ -232,11 +222,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) { } owned, shared := splitOwnedShared(repeaters) - reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(ctx, uid) - if err != nil { - http.Error(w, "could not load org state", http.StatusInternalServerError) - return - } orgs, err := s.Store.ListOrgsForUser(ctx, uid) if err != nil { http.Error(w, "could not load organizations", http.StatusInternalServerError) @@ -277,7 +262,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) { "Orgs": first(orgs, 5), "Mapped": mapped, "Recent": recent, - "Reconsent": reconsent, "ShareCounts": shareCounts, "Error": r.URL.Query().Get("error"), }) diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go index 62a175c..1ca5346 100644 --- a/internal/marketing/marketing.go +++ b/internal/marketing/marketing.go @@ -44,8 +44,7 @@ func (s *Handlers) Routes() chi.Router { r.Get("/orgs", s.pageOrgs) // public organization directory r.Get("/orgs/{id}", s.pageOrgPublic) // public org page r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map - r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config - r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) // public requested access + r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config return r } diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go index dc75c8c..0e4b5aa 100644 --- a/internal/marketing/orgs.go +++ b/internal/marketing/orgs.go @@ -119,32 +119,6 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { s.Render(w, r, "org_config.html", data) } -// pageOrgPermissions renders an org's requested-access policy read-only for -// anonymous visitors (so prospective members can see it before joining). -func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return - } - org, err := s.Store.GetOrg(r.Context(), id) - if err != nil { - http.NotFound(w, r) - return - } - pv, err := web.BuildPermissionsView(r.Context(), s.Store, id) - if err != nil { - http.Error(w, "could not load policy", http.StatusInternalServerError) - return - } - s.Render(w, r, "org_permissions.html", map[string]any{ - "Org": org, - "Nav": web.OrgNav(org.Slug, "permissions", false), - "CanEdit": false, - "Perms": pv, - }) -} - // pageOrgRepeaters renders an org's public repeaters (those opted into the public // map) with a map, for anonymous visitors. func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) { diff --git a/internal/marketing/templates/landing.html b/internal/marketing/templates/landing.html index cc3872d..8163011 100644 --- a/internal/marketing/templates/landing.html +++ b/internal/marketing/templates/landing.html @@ -46,7 +46,7 @@ {{template "icon-share" ""}}

Share with precision

-

Share access to your repeaters with single-use share links and command-level permissions. Contribute your repeater to an Organization will full transparency into which commands they can run.

+

Share access to your repeaters with single-use share links and command-level permissions. Join an organization and your repeaters are shared with it automatically — opt any out, or limit which commands it can run, whenever you like.

diff --git a/internal/store/commands.go b/internal/store/commands.go index 3ff3b25..05b161f 100644 --- a/internal/store/commands.go +++ b/internal/store/commands.go @@ -21,22 +21,26 @@ type Command struct { Description string // Feature is the grouping area (e.g. "Radio", "Region") and Operation is the // read/write/delete/action bucket, both used by the review/catalog UIs. - Feature string - Operation string - Risky bool - InShareDefault bool - InOrgMemberDefault bool - InOrgAdminDefault bool + Feature string + Operation string + Risky bool + // InShareDefault seeds the command set offered for a new one-off share. + InShareDefault bool + // OrgMemberAllowed / OrgAdminAllowed are the site-admin-controlled ceiling of + // what an org member / admin may ever run on a contributed repeater. (No longer + // just a seed — these are the authoritative per-tier limits.) + OrgMemberAllowed bool + OrgAdminAllowed bool } const commandCols = `id, key, template, category, args, arity, description, feature, operation, risky, - in_share_default, in_org_member_default, in_org_admin_default` + in_share_default, org_member_allowed, org_admin_allowed` func scanCommand(row pgx.Row) (*Command, error) { var c Command err := row.Scan(&c.ID, &c.Key, &c.Template, &c.Category, &c.Args, &c.Arity, &c.Description, &c.Feature, &c.Operation, &c.Risky, - &c.InShareDefault, &c.InOrgMemberDefault, &c.InOrgAdminDefault) + &c.InShareDefault, &c.OrgMemberAllowed, &c.OrgAdminAllowed) if err != nil { return nil, err } @@ -66,7 +70,7 @@ func (s *Store) GetCommand(ctx context.Context, id int64) (*Command, error) { func (s *Store) UpdateCommandFlags(ctx context.Context, id int64, risky, share, orgMember, orgAdmin bool) error { _, err := s.pool.Exec(ctx, ` UPDATE command_catalog - SET risky = $2, in_share_default = $3, in_org_member_default = $4, in_org_admin_default = $5 + SET risky = $2, in_share_default = $3, org_member_allowed = $4, org_admin_allowed = $5 WHERE id = $1`, id, risky, share, orgMember, orgAdmin) if err != nil { return fmt.Errorf("update command flags: %w", err) diff --git a/internal/store/migrations/0022_simplify_org_permissions.sql b/internal/store/migrations/0022_simplify_org_permissions.sql new file mode 100644 index 0000000..4e4ce6e --- /dev/null +++ b/internal/store/migrations/0022_simplify_org_permissions.sql @@ -0,0 +1,72 @@ +-- +goose Up +-- Rework org command permissions away from per-org versioned policies + repeater +-- contribution/consent toward a simpler model: +-- (a) the site catalog flags are the hard per-tier ceiling of what an org may +-- ever run (org_member_allowed / org_admin_allowed, renamed from the old +-- "default" flags that only seeded versions), +-- (b) a repeater participates in every org its owner belongs to unless the +-- owner opts it out (org_repeater_excludes), +-- (c) an owner may optionally restrict, per org, which of the ceiling commands +-- that org may run on their repeaters (org_command_optin); no rows = the +-- full ceiling applies. +-- The versioned policy tables and the consent-pinned contribution table go away. +DROP TABLE org_repeaters; +DROP TABLE org_permission_commands; +DROP TABLE org_permission_versions; + +ALTER TABLE command_catalog RENAME COLUMN in_org_member_default TO org_member_allowed; +ALTER TABLE command_catalog RENAME COLUMN in_org_admin_default TO org_admin_allowed; + +-- Opt-out set: a repeater participates in org O iff its owner is a member of O +-- AND there is no exclude row. Absence = included (the common case writes nothing). +CREATE TABLE org_repeater_excludes ( + org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE, + PRIMARY KEY (org_id, repeater_id) +); +CREATE INDEX org_repeater_excludes_repeater_id_idx ON org_repeater_excludes(repeater_id); + +-- Per-(owner, org) optional command allowlist. No rows for an (org, owner) pair +-- = permissive (the site ceiling applies); ≥1 row = restricted to exactly those +-- commands. (Note: this is the opposite default from share_commands, where no +-- rows means deny-all.) +CREATE TABLE org_command_optin ( + org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE, + PRIMARY KEY (org_id, owner_id, command_id) +); + +-- +goose Down +DROP TABLE org_command_optin; +DROP TABLE org_repeater_excludes; + +ALTER TABLE command_catalog RENAME COLUMN org_admin_allowed TO in_org_admin_default; +ALTER TABLE command_catalog RENAME COLUMN org_member_allowed TO in_org_member_default; + +CREATE TABLE org_permission_versions ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + version INT NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, version) +); + +CREATE TABLE org_permission_commands ( + version_id BIGINT NOT NULL REFERENCES org_permission_versions(id) ON DELETE CASCADE, + command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE, + tier TEXT NOT NULL CHECK (tier IN ('admin', 'member')), + PRIMARY KEY (version_id, command_id, tier) +); + +CREATE TABLE org_repeaters ( + org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE, + consented_version_id BIGINT NOT NULL REFERENCES org_permission_versions(id), + contributed_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + contributed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (org_id, repeater_id) +); +CREATE INDEX org_repeaters_repeater_id_idx ON org_repeaters(repeater_id); diff --git a/internal/store/org_command_optin.go b/internal/store/org_command_optin.go new file mode 100644 index 0000000..1218b2b --- /dev/null +++ b/internal/store/org_command_optin.go @@ -0,0 +1,43 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// An owner's optional per-org command allowlist. No rows for an (org, owner) pair +// means permissive — the site ceiling (org_member_allowed / org_admin_allowed) +// applies unchanged. One or more rows restrict that org to exactly the listed +// commands on the owner's repeaters (still intersected with the ceiling and tier). + +// OrgOptInCommandIDs returns the command ids an owner has opted into for an org. +// An empty result means no restriction (permissive). +func (s *Store) OrgOptInCommandIDs(ctx context.Context, orgID, ownerID int64) ([]int64, error) { + rows, err := s.pool.Query(ctx, + `SELECT command_id FROM org_command_optin WHERE org_id = $1 AND owner_id = $2`, orgID, ownerID) + if err != nil { + return nil, fmt.Errorf("org opt-in commands: %w", err) + } + return collectRows(rows, scanID) +} + +// SetOrgOptIn replaces an owner's opt-in command list for an org. Passing no ids +// clears the restriction (reverts to permissive). +func (s *Store) SetOrgOptIn(ctx context.Context, orgID, ownerID int64, commandIDs []int64) error { + return s.inTx(ctx, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, + `DELETE FROM org_command_optin WHERE org_id = $1 AND owner_id = $2`, orgID, ownerID); err != nil { + return fmt.Errorf("clear org opt-in: %w", err) + } + for _, id := range commandIDs { + if _, err := tx.Exec(ctx, + `INSERT INTO org_command_optin (org_id, owner_id, command_id) VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING`, orgID, ownerID, id); err != nil { + return fmt.Errorf("insert org opt-in: %w", err) + } + } + return nil + }) +} diff --git a/internal/store/org_permissions.go b/internal/store/org_permissions.go deleted file mode 100644 index b9b0da1..0000000 --- a/internal/store/org_permissions.go +++ /dev/null @@ -1,116 +0,0 @@ -package store - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5" -) - -// CurrentVersion returns the org's latest permission version (id and number). -func (s *Store) CurrentVersion(ctx context.Context, orgID int64) (id int64, version int, err error) { - err = s.pool.QueryRow(ctx, - `SELECT id, version FROM org_permission_versions WHERE org_id = $1 ORDER BY version DESC LIMIT 1`, - orgID).Scan(&id, &version) - if err != nil { - return 0, 0, notFoundOr(err, "current version") - } - return id, version, nil -} - -// VersionCommandIDs returns the command ids in a version, split by tier. -func (s *Store) VersionCommandIDs(ctx context.Context, versionID int64) (admin, member []int64, err error) { - rows, err := s.pool.Query(ctx, - `SELECT command_id, tier FROM org_permission_commands WHERE version_id = $1`, versionID) - if err != nil { - return nil, nil, fmt.Errorf("version commands: %w", err) - } - defer rows.Close() - for rows.Next() { - var id int64 - var tier string - if err := rows.Scan(&id, &tier); err != nil { - return nil, nil, err - } - if tier == "admin" { - admin = append(admin, id) - } else { - member = append(member, id) - } - } - return admin, member, rows.Err() -} - -// VersionNumber returns the version number for a permission version id. -func (s *Store) VersionNumber(ctx context.Context, versionID int64) (int, error) { - var v int - err := s.pool.QueryRow(ctx, - `SELECT version FROM org_permission_versions WHERE id = $1`, versionID).Scan(&v) - if err != nil { - return 0, notFoundOr(err, "version number") - } - return v, nil -} - -// VersionNote is a changelog entry. -type VersionNote struct { - Version int - Note string -} - -// VersionNotesSince returns the notes for org versions newer than afterVersion, -// oldest first — the changelog an owner reviews before re-consenting. -func (s *Store) VersionNotesSince(ctx context.Context, orgID int64, afterVersion int) ([]VersionNote, error) { - rows, err := s.pool.Query(ctx, - `SELECT version, note FROM org_permission_versions - WHERE org_id = $1 AND version > $2 ORDER BY version`, orgID, afterVersion) - if err != nil { - return nil, fmt.Errorf("version notes: %w", err) - } - return collectRows(rows, func(r pgx.Row) (VersionNote, error) { - var n VersionNote - err := r.Scan(&n.Version, &n.Note) - return n, err - }) -} - -// PublishVersion creates the org's next permission version with the given -// admin/member command sets, returning the new version number. -func (s *Store) PublishVersion(ctx context.Context, orgID int64, note string, createdBy int64, adminIDs, memberIDs []int64) (int, error) { - var next int - err := s.inTx(ctx, func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, - `SELECT COALESCE(max(version), 0) + 1 FROM org_permission_versions WHERE org_id = $1`, - orgID).Scan(&next); err != nil { - return fmt.Errorf("next version: %w", err) - } - var versionID int64 - if err := tx.QueryRow(ctx, - `INSERT INTO org_permission_versions (org_id, version, note, created_by) - VALUES ($1, $2, $3, $4) RETURNING id`, - orgID, next, note, createdBy).Scan(&versionID); err != nil { - return fmt.Errorf("insert version: %w", err) - } - insert := func(ids []int64, tier string) error { - for _, id := range ids { - if _, err := tx.Exec(ctx, - `INSERT INTO org_permission_commands (version_id, command_id, tier) VALUES ($1, $2, $3) - ON CONFLICT DO NOTHING`, versionID, id, tier); err != nil { - return err - } - } - return nil - } - if err := insert(adminIDs, "admin"); err != nil { - return fmt.Errorf("insert admin commands: %w", err) - } - if err := insert(memberIDs, "member"); err != nil { - return fmt.Errorf("insert member commands: %w", err) - } - return nil - }) - if err != nil { - return 0, err - } - return next, nil -} diff --git a/internal/store/org_repeaters.go b/internal/store/org_repeaters.go index 1978c53..d517fc4 100644 --- a/internal/store/org_repeaters.go +++ b/internal/store/org_repeaters.go @@ -7,48 +7,46 @@ import ( "github.com/jackc/pgx/v5" ) -// RepeaterOrg describes an org a repeater is contributed to, with the version -// the owner consented to vs the org's current version (current > consented means -// re-consent is available). +// A repeater participates in an org iff its owner is a member of that org and the +// owner hasn't opted it out (no org_repeater_excludes row). This file builds the +// org↔repeater listings around that rule and manages the opt-out set. + +// RepeaterOrg is an org a repeater participates in. type RepeaterOrg struct { - OrgID int64 - OrgSlug string - OrgName string - ConsentedVersion int - CurrentVersion int + OrgID int64 + OrgSlug string + OrgName string } -// NeedsReconsent reports whether the org has published a newer version than the -// owner consented to. -func (r RepeaterOrg) NeedsReconsent() bool { return r.CurrentVersion > r.ConsentedVersion } +// RepeaterOrgMembership is an org the repeater's owner belongs to, with whether +// the owner has opted this repeater out of it — drives the per-org include/exclude +// toggles on the owner's repeater/share pages. +type RepeaterOrgMembership struct { + OrgID int64 + OrgSlug string + OrgName string + Excluded bool +} -// ContributeRepeater contributes a repeater to an org pinned to consentedVersionID -// (also used to re-consent: re-pins to a newer version). -func (s *Store) ContributeRepeater(ctx context.Context, orgID, repeaterID, consentedVersionID, contributedBy int64) error { - _, err := s.pool.Exec(ctx, ` - INSERT INTO org_repeaters (org_id, repeater_id, consented_version_id, contributed_by) - VALUES ($1, $2, $3, $4) - ON CONFLICT (org_id, repeater_id) - DO UPDATE SET consented_version_id = EXCLUDED.consented_version_id, - contributed_by = EXCLUDED.contributed_by, contributed_at = now()`, - orgID, repeaterID, consentedVersionID, contributedBy) +// SetRepeaterOrgExcluded opts a repeater out of (excluded=true) or back into +// (excluded=false) an org. Idempotent. +func (s *Store) SetRepeaterOrgExcluded(ctx context.Context, orgID, repeaterID int64, excluded bool) error { + var err error + if excluded { + _, err = s.pool.Exec(ctx, + `INSERT INTO org_repeater_excludes (org_id, repeater_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, orgID, repeaterID) + } else { + _, err = s.pool.Exec(ctx, + `DELETE FROM org_repeater_excludes WHERE org_id = $1 AND repeater_id = $2`, orgID, repeaterID) + } if err != nil { - return fmt.Errorf("contribute repeater: %w", err) + return fmt.Errorf("set repeater org excluded: %w", err) } return nil } -// WithdrawRepeater removes a repeater from an org. -func (s *Store) WithdrawRepeater(ctx context.Context, orgID, repeaterID int64) error { - _, err := s.pool.Exec(ctx, - `DELETE FROM org_repeaters WHERE org_id = $1 AND repeater_id = $2`, orgID, repeaterID) - if err != nil { - return fmt.Errorf("withdraw repeater: %w", err) - } - return nil -} - -// OrgRepeaterInfo is a contributed repeater shown on the org page. +// OrgRepeaterInfo is a participating repeater shown on the org page. type OrgRepeaterInfo struct { RepeaterID int64 RepeaterPublicID string @@ -58,16 +56,17 @@ type OrgRepeaterInfo struct { Lat, Lon float64 } -// ListOrgRepeaters returns the repeaters contributed to an org (with location -// when the owner consented to storing it). +// ListOrgRepeaters returns the repeaters participating in an org (owned by a +// member, not opted out), with location when the owner stored it. func (s *Store) ListOrgRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) { rows, err := s.pool.Query(ctx, ` SELECT r.id, r.public_id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'), r.latitude, r.longitude - FROM org_repeaters orp - JOIN repeaters r ON r.id = orp.repeater_id + FROM repeaters r + JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id JOIN users ou ON ou.id = r.owner_id - WHERE orp.org_id = $1 + WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = $1 AND e.repeater_id = r.id) ORDER BY r.name`, orgID) if err != nil { return nil, fmt.Errorf("list org repeaters: %w", err) @@ -89,16 +88,18 @@ func setLocation(ri *OrgRepeaterInfo, lat, lon *float64) { } } -// ListPublicMapRepeaters returns the contributed repeaters an org may show on +// ListPublicMapRepeaters returns the participating repeaters an org may show on // its public map: those whose owner opted into public_map and have coordinates. func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) { rows, err := s.pool.Query(ctx, ` SELECT r.id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'), r.latitude, r.longitude - FROM org_repeaters orp - JOIN repeaters r ON r.id = orp.repeater_id + FROM repeaters r + JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id JOIN users ou ON ou.id = r.owner_id - WHERE orp.org_id = $1 AND r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL + WHERE r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = $1 AND e.repeater_id = r.id) ORDER BY r.name`, orgID) if err != nil { return nil, fmt.Errorf("list public map repeaters: %w", err) @@ -112,62 +113,46 @@ func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgR }) } -// ConsentedVersionID returns the permission version a repeater is pinned to for -// an org, or (0, false) if it isn't contributed there. -func (s *Store) ConsentedVersionID(ctx context.Context, orgID, repeaterID int64) (int64, bool, error) { - var id int64 - err := s.pool.QueryRow(ctx, - `SELECT consented_version_id FROM org_repeaters WHERE org_id = $1 AND repeater_id = $2`, - orgID, repeaterID).Scan(&id) - if err != nil { - return 0, false, nil //nolint:nilerr // absence is not an error here - } - return id, true, nil -} - -// OwnedRepeatersNeedingReconsent returns the set of repeater ids owned by the -// user that are contributed to an org which has published a newer version than -// the owner consented to. -func (s *Store) OwnedRepeatersNeedingReconsent(ctx context.Context, ownerID int64) (map[int64]bool, error) { - rows, err := s.pool.Query(ctx, ` - SELECT DISTINCT orp.repeater_id - FROM org_repeaters orp - JOIN repeaters r ON r.id = orp.repeater_id AND r.owner_id = $1 - JOIN org_permission_versions cv ON cv.id = orp.consented_version_id - WHERE cv.version < (SELECT max(version) FROM org_permission_versions WHERE org_id = orp.org_id)`, - ownerID) - if err != nil { - return nil, fmt.Errorf("reconsent set: %w", err) - } - defer rows.Close() - out := map[int64]bool{} - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return nil, err - } - out[id] = true - } - return out, rows.Err() -} - -// ListRepeaterOrgs returns the orgs a repeater is contributed to, with consented -// vs current version numbers. +// ListRepeaterOrgs returns the orgs a repeater participates in (owner is a member +// and hasn't opted it out). func (s *Store) ListRepeaterOrgs(ctx context.Context, repeaterID int64) ([]RepeaterOrg, error) { rows, err := s.pool.Query(ctx, ` - SELECT o.id, o.slug, o.name, cv.version, - (SELECT max(version) FROM org_permission_versions WHERE org_id = o.id) - FROM org_repeaters orp - JOIN organizations o ON o.id = orp.org_id - JOIN org_permission_versions cv ON cv.id = orp.consented_version_id - WHERE orp.repeater_id = $1 + SELECT o.id, o.slug, o.name + FROM repeaters r + JOIN org_members om ON om.user_id = r.owner_id + JOIN organizations o ON o.id = om.org_id + WHERE r.id = $1 + AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = o.id AND e.repeater_id = r.id) ORDER BY o.name`, repeaterID) if err != nil { return nil, fmt.Errorf("list repeater orgs: %w", err) } return collectRows(rows, func(r pgx.Row) (RepeaterOrg, error) { var ro RepeaterOrg - err := r.Scan(&ro.OrgID, &ro.OrgSlug, &ro.OrgName, &ro.ConsentedVersion, &ro.CurrentVersion) + err := r.Scan(&ro.OrgID, &ro.OrgSlug, &ro.OrgName) return ro, err }) } + +// ListRepeaterOrgMemberships returns every org the repeater's owner belongs to, +// flagged with whether the owner has opted this repeater out of it. +func (s *Store) ListRepeaterOrgMemberships(ctx context.Context, repeaterID int64) ([]RepeaterOrgMembership, error) { + rows, err := s.pool.Query(ctx, ` + SELECT o.id, o.slug, o.name, + EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = o.id AND e.repeater_id = r.id) + FROM repeaters r + JOIN org_members om ON om.user_id = r.owner_id + JOIN organizations o ON o.id = om.org_id + WHERE r.id = $1 + ORDER BY o.name`, repeaterID) + if err != nil { + return nil, fmt.Errorf("list repeater org memberships: %w", err) + } + return collectRows(rows, func(r pgx.Row) (RepeaterOrgMembership, error) { + var m RepeaterOrgMembership + err := r.Scan(&m.OrgID, &m.OrgSlug, &m.OrgName, &m.Excluded) + return m, err + }) +} diff --git a/internal/store/orgs.go b/internal/store/orgs.go index bbcbb01..ca9916d 100644 --- a/internal/store/orgs.go +++ b/internal/store/orgs.go @@ -124,25 +124,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O o.ID, creatorID); err != nil { return fmt.Errorf("add creator: %w", err) } - - // Seed version 1 from the catalog default sets. - var versionID int64 - if err := tx.QueryRow(ctx, - `INSERT INTO org_permission_versions (org_id, version, note, created_by) - VALUES ($1, 1, 'Initial policy', $2) RETURNING id`, - o.ID, creatorID).Scan(&versionID); err != nil { - return fmt.Errorf("seed version: %w", err) - } - if _, err := tx.Exec(ctx, - `INSERT INTO org_permission_commands (version_id, command_id, tier) - SELECT $1, id, 'admin' FROM command_catalog WHERE in_org_admin_default`, versionID); err != nil { - return fmt.Errorf("seed admin commands: %w", err) - } - if _, err := tx.Exec(ctx, - `INSERT INTO org_permission_commands (version_id, command_id, tier) - SELECT $1, id, 'member' FROM command_catalog WHERE in_org_member_default`, versionID); err != nil { - return fmt.Errorf("seed member commands: %w", err) - } + // No permission policy to seed: what an org may run is the site-wide + // catalog ceiling, and owners restrict per org via org_command_optin. return nil }) if err != nil { @@ -302,7 +285,10 @@ func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgS FROM ( SELECT o.id, o.slug, o.name, o.description, o.region, o.created_at, (SELECT count(*) FROM org_members m WHERE m.org_id = o.id) AS member_count, - (SELECT count(*) FROM org_repeaters orp WHERE orp.org_id = o.id) AS repeater_count + (SELECT count(*) FROM repeaters r + JOIN org_members om ON om.org_id = o.id AND om.user_id = r.owner_id + WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = o.id AND e.repeater_id = r.id)) AS repeater_count FROM organizations o %s ) t @@ -333,7 +319,10 @@ func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgS func (s *Store) OrgCounts(ctx context.Context, orgID int64) (members, repeaters int, err error) { err = s.pool.QueryRow(ctx, ` SELECT (SELECT count(*) FROM org_members WHERE org_id = $1), - (SELECT count(*) FROM org_repeaters WHERE org_id = $1)`, orgID). + (SELECT count(*) FROM repeaters r + JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id + WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = $1 AND e.repeater_id = r.id))`, orgID). Scan(&members, &repeaters) if err != nil { return 0, 0, fmt.Errorf("org counts: %w", err) diff --git a/internal/store/orgs_resolution_test.go b/internal/store/orgs_resolution_test.go index 8c95903..8789093 100644 --- a/internal/store/orgs_resolution_test.go +++ b/internal/store/orgs_resolution_test.go @@ -48,10 +48,22 @@ func TestOrgCommandResolution(t *testing.T) { } return id } - // poweroff is a risky, owner-only command that's in no org tier — used to - // check that even an org admin can't run a command outside the policy. + // poweroff is a risky, owner-only command kept out of both org tiers — used to + // check that even an org admin can't run a command outside the site ceiling. setRadio, advert, poweroff, setTx := cmdID("set.radio"), cmdID("advert"), cmdID("poweroff"), cmdID("set.tx") + // Set the site ceiling: admin tier = {set.radio, set.tx}, member tier = {advert}, + // poweroff in neither. (risky/share flags don't matter for these checks.) + setCeiling := func(id int64, member, admin bool) { + if err := st.UpdateCommandFlags(ctx, id, false, false, member, admin); err != nil { + t.Fatalf("set ceiling %d: %v", id, err) + } + } + setCeiling(setRadio, false, true) + setCeiling(setTx, false, true) + setCeiling(advert, true, false) + setCeiling(poweroff, false, false) + mkUser := func(name string) int64 { u, err := st.CreateUser(ctx, name, "") if err != nil { @@ -82,15 +94,7 @@ func TestOrgCommandResolution(t *testing.T) { if err := st.AddOrgMember(ctx, org.ID, plainM, "member"); err != nil { t.Fatal(err) } - - // Controlled v2: admin={set.radio}, member={advert}; contribute pinned to it. - if _, err := st.PublishVersion(ctx, org.ID, "v2", owner, []int64{setRadio}, []int64{advert}); err != nil { - t.Fatal(err) - } - vid, _, _ := st.CurrentVersion(ctx, org.ID) - if err := st.ContributeRepeater(ctx, org.ID, rep.ID, vid, owner); err != nil { - t.Fatal(err) - } + // The owner's repeater participates in the org automatically (no opt-out). can := func(u, c int64) bool { ok, err := st.CanSendCommand(ctx, u, rep.ID, c) @@ -108,9 +112,10 @@ func TestOrgCommandResolution(t *testing.T) { // Owner: anything. check("owner/poweroff", can(owner, poweroff), true) - // Org-admin: admin tier + member tier (⊇), but not commands outside policy. + // Org-admin: admin tier + member tier (⊇), but not commands outside the ceiling. check("admin/set.radio", can(adminM, setRadio), true) check("admin/advert", can(adminM, advert), true) + check("admin/set.tx", can(adminM, setTx), true) check("admin/poweroff", can(adminM, poweroff), false) // Plain member: member tier only. check("member/advert", can(plainM, advert), true) @@ -118,24 +123,29 @@ func TestOrgCommandResolution(t *testing.T) { // Outsider: nothing. check("outsider/advert", can(outsider, advert), false) - // Add set.tx to admin in v3; repeater still pinned to v2 → blocked until re-consent. - if _, err := st.PublishVersion(ctx, org.ID, "v3 add set.tx", owner, []int64{setRadio, setTx}, []int64{advert}); err != nil { + // Owner opts the org into only {advert}: the admin loses the admin-tier + // commands not in the list, but keeps advert. + if err := st.SetOrgOptIn(ctx, org.ID, owner, []int64{advert}); err != nil { t.Fatal(err) } - check("admin/set.tx before reconsent", can(adminM, setTx), false) - v3, _, _ := st.CurrentVersion(ctx, org.ID) - if err := st.ContributeRepeater(ctx, org.ID, rep.ID, v3, owner); err != nil { // re-consent + check("admin/set.radio with opt-in", can(adminM, setRadio), false) + check("admin/advert with opt-in", can(adminM, advert), true) + // Clearing the opt-in restores the full ceiling. + if err := st.SetOrgOptIn(ctx, org.ID, owner, nil); err != nil { t.Fatal(err) } - check("admin/set.tx after reconsent", can(adminM, setTx), true) + check("admin/set.radio after clear", can(adminM, setRadio), true) - // Remove set.radio in v4 (admin={set.tx}); removal auto-applies even though - // the repeater is still consented to v3 (which had set.radio). - if _, err := st.PublishVersion(ctx, org.ID, "v4 drop set.radio", owner, []int64{setTx}, []int64{advert}); err != nil { + // Opting the repeater out of the org blocks all org access regardless of tier. + if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, true); err != nil { t.Fatal(err) } - check("admin/set.radio after removal", can(adminM, setRadio), false) - check("admin/set.tx still", can(adminM, setTx), true) + check("admin/advert when excluded", can(adminM, advert), false) + check("member/advert when excluded", can(plainM, advert), false) + if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, false); err != nil { + t.Fatal(err) + } + check("admin/advert when re-included", can(adminM, advert), true) } func TestOrgRepeaterAccess(t *testing.T) { @@ -150,8 +160,7 @@ func TestOrgRepeaterAccess(t *testing.T) { } org, _ := st.CreateOrg(ctx, "Org", owner.ID) _ = st.AddOrgMember(ctx, org.ID, member.ID, "member") - vid, _, _ := st.CurrentVersion(ctx, org.ID) - _ = st.ContributeRepeater(ctx, org.ID, rep.ID, vid, owner.ID) + // The owner's repeater participates in the org automatically. // Member can fetch (and thus operate) the repeater via org access... if _, err := st.GetRepeaterForUser(ctx, member.ID, rep.ID); err != nil { diff --git a/internal/store/repeaters.go b/internal/store/repeaters.go index 06b44e0..5b0910e 100644 --- a/internal/store/repeaters.go +++ b/internal/store/repeaters.go @@ -151,8 +151,12 @@ func (s *Store) GetRepeaterForUser(ctx context.Context, userID, repeaterID int64 WHERE r.id = $2 AND (r.owner_id = $1 OR r.id IN (SELECT repeater_id FROM repeater_shares WHERE user_id = $1) - OR r.id IN (SELECT orp.repeater_id FROM org_repeaters orp - JOIN org_members om ON om.org_id = orp.org_id AND om.user_id = $1))`, + OR EXISTS (SELECT 1 + FROM org_members ownm + JOIN org_members usrm ON usrm.org_id = ownm.org_id AND usrm.user_id = $1 + WHERE ownm.user_id = r.owner_id + AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = ownm.org_id AND e.repeater_id = r.id)))`, userID, repeaterID) r, err := scanRepeater(row) if err != nil { diff --git a/internal/store/reset.go b/internal/store/reset.go new file mode 100644 index 0000000..9507f1d --- /dev/null +++ b/internal/store/reset.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// resetPreservedTables are the tables Reset keeps: everything needed to still sign +// in (users + their passkeys + live sessions), the server-wide MeshCore identity +// (so re-added repeaters still trust MeshTender), the command catalog (site config, +// not user data), and goose's migration bookkeeping. Everything else — orgs, +// repeaters, shares, config profiles, logs, console/auth ephemera — is wiped. +var resetPreservedTables = map[string]bool{ + "users": true, + "webauthn_credentials": true, + "sessions": true, + "server_identity": true, + "command_catalog": true, + "goose_db_version": true, +} + +// Reset truncates all application data except the identity/login/catalog tables +// (see resetPreservedTables). It's a development convenience: after a reset you can +// still log in and your repeaters still trust the server, but you start fresh on +// orgs, repeaters, and everything else. Discovers tables dynamically so new +// migrations are covered automatically. +func (s *Store) Reset(ctx context.Context) error { + rows, err := s.pool.Query(ctx, + `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`) + if err != nil { + return fmt.Errorf("reset: list tables: %w", err) + } + tables, err := collectRows(rows, func(r pgx.Row) (string, error) { + var t string + return t, r.Scan(&t) + }) + if err != nil { + return fmt.Errorf("reset: scan tables: %w", err) + } + var quoted []string + for _, t := range tables { + if !resetPreservedTables[t] { + quoted = append(quoted, pgx.Identifier{t}.Sanitize()) + } + } + if len(quoted) == 0 { + return nil + } + // RESTART IDENTITY resets sequences; CASCADE handles FK ordering (no preserved + // table references a wiped one, so CASCADE never reaches the kept tables). + stmt := "TRUNCATE " + strings.Join(quoted, ", ") + " RESTART IDENTITY CASCADE" + if _, err := s.pool.Exec(ctx, stmt); err != nil { + return fmt.Errorf("reset: truncate: %w", err) + } + return nil +} diff --git a/internal/store/reset_test.go b/internal/store/reset_test.go new file mode 100644 index 0000000..64ed7cc --- /dev/null +++ b/internal/store/reset_test.go @@ -0,0 +1,74 @@ +package store + +import ( + "strings" + "testing" +) + +func TestReset(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + + count := func(table string) int { + var n int + if err := st.pool.QueryRow(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + return n + } + + // Seed preserved data (user + server identity) and disposable data (repeater + org). + owner, err := st.CreateUser(ctx, "owner", "") + if err != nil { + t.Fatal(err) + } + if err := st.InsertServerIdentity(ctx, strings.Repeat("a", 64), []byte("sealed")); err != nil { + t.Fatal(err) + } + rep, err := st.CreateRepeater(ctx, &Repeater{ + OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("b", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateOrg(ctx, "Region", owner.ID); err != nil { + t.Fatal(err) + } + _ = rep + + catalogBefore := count("command_catalog") + if catalogBefore == 0 { + t.Fatal("expected a seeded command catalog") + } + + if err := st.Reset(ctx); err != nil { + t.Fatalf("reset: %v", err) + } + + // Preserved: login + identity + catalog survive. + if got := count("users"); got != 1 { + t.Errorf("users after reset = %d, want 1", got) + } + if got := count("server_identity"); got != 1 { + t.Errorf("server_identity after reset = %d, want 1", got) + } + if got := count("command_catalog"); got != catalogBefore { + t.Errorf("command_catalog after reset = %d, want %d", got, catalogBefore) + } + // Wiped: user content is gone. + if got := count("repeaters"); got != 0 { + t.Errorf("repeaters after reset = %d, want 0", got) + } + if got := count("organizations"); got != 0 { + t.Errorf("organizations after reset = %d, want 0", got) + } + if got := count("org_members"); got != 0 { + t.Errorf("org_members after reset = %d, want 0", got) + } + + // The kept user can still be looked up (login still works). + if _, err := st.GetUserByID(ctx, owner.ID); err != nil { + t.Errorf("GetUserByID after reset: %v", err) + } +} diff --git a/internal/store/share_commands.go b/internal/store/share_commands.go index f8e56db..3ac724f 100644 --- a/internal/store/share_commands.go +++ b/internal/store/share_commands.go @@ -11,10 +11,11 @@ import ( // to repeaterID. Allowed if any of: // - they own the repeater (any command), or // - a share grants them that specific command, or -// - the repeater is contributed to an org they're a member of, and the command -// is in BOTH the consented and the current policy version for their effective -// tier (member → member tier; admin → member OR admin tier). This implements -// effective = consented ∩ current, with admins ⊇ members. +// - the repeater participates in an org they and the owner both belong to (the +// owner is a member and hasn't excluded the repeater), the command is within +// the site ceiling for their tier (member → org_member_allowed; admin → +// org_member_allowed OR org_admin_allowed), AND the owner either set no opt-in +// list for that org (permissive) or listed this command. func (s *Store) CanSendCommand(ctx context.Context, userID, repeaterID, commandID int64) (bool, error) { var ok bool err := s.pool.QueryRow(ctx, ` @@ -23,20 +24,20 @@ func (s *Store) CanSendCommand(ctx context.Context, userID, repeaterID, commandI OR EXISTS (SELECT 1 FROM share_commands WHERE repeater_id = $2 AND user_id = $1 AND command_id = $3) OR EXISTS ( SELECT 1 - FROM org_repeaters orp - JOIN org_members om ON om.org_id = orp.org_id AND om.user_id = $1 - JOIN org_permission_commands consented - ON consented.version_id = orp.consented_version_id - AND consented.command_id = $3 - AND (consented.tier = 'member' OR (om.role = 'admin' AND consented.tier = 'admin')) - JOIN org_permission_versions cur - ON cur.org_id = orp.org_id - AND cur.version = (SELECT max(version) FROM org_permission_versions WHERE org_id = orp.org_id) - JOIN org_permission_commands current - ON current.version_id = cur.id - AND current.command_id = $3 - AND (current.tier = 'member' OR (om.role = 'admin' AND current.tier = 'admin')) - WHERE orp.repeater_id = $2 + FROM repeaters r + JOIN org_members ownm ON ownm.user_id = r.owner_id -- owner's org memberships + JOIN org_members usrm ON usrm.org_id = ownm.org_id AND usrm.user_id = $1 + JOIN command_catalog c ON c.id = $3 + WHERE r.id = $2 + AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = ownm.org_id AND e.repeater_id = r.id) + AND (c.org_member_allowed OR (usrm.role = 'admin' AND c.org_admin_allowed)) + AND ( + NOT EXISTS (SELECT 1 FROM org_command_optin o + WHERE o.org_id = ownm.org_id AND o.owner_id = r.owner_id) + OR EXISTS (SELECT 1 FROM org_command_optin o + WHERE o.org_id = ownm.org_id AND o.owner_id = r.owner_id AND o.command_id = $3) + ) )`, userID, repeaterID, commandID).Scan(&ok) if err != nil { diff --git a/internal/store/shares.go b/internal/store/shares.go index 4b734b6..7139a82 100644 --- a/internal/store/shares.go +++ b/internal/store/shares.go @@ -75,16 +75,19 @@ func (s *Store) RemoveShare(ctx context.Context, repeaterID, userID int64) error // ShareCounts summarizes how widely a repeater is shared. type ShareCounts struct { Users int // direct user shares - Orgs int // organization contributions + Orgs int // organizations this repeater participates in } -// RepeaterSharingCounts returns per-repeater share and org-contribution counts +// RepeaterSharingCounts returns per-repeater share and org-participation counts // for every repeater owned by ownerID, keyed by repeater id. func (s *Store) RepeaterSharingCounts(ctx context.Context, ownerID int64) (map[int64]ShareCounts, error) { rows, err := s.pool.Query(ctx, ` SELECT r.id, (SELECT count(*) FROM repeater_shares rs WHERE rs.repeater_id = r.id), - (SELECT count(*) FROM org_repeaters orp WHERE orp.repeater_id = r.id) + (SELECT count(*) FROM org_members om + WHERE om.user_id = r.owner_id + AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = om.org_id AND e.repeater_id = r.id)) FROM repeaters r WHERE r.owner_id = $1`, ownerID) if err != nil { return nil, fmt.Errorf("repeater sharing counts: %w", err) diff --git a/internal/web/env.go b/internal/web/env.go index 3936ecf..0111022 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -22,14 +22,14 @@ import ( "github.com/jleight/meshtender/internal/store" ) -//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_permissions.html templates/org_repeaters.html +//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_repeaters.html var sharedTemplatesFS embed.FS // sharedPages are full content pages (not just layout partials) that more than // one surface renders. They're composed onto the base layout for every surface, // so the root host (anonymous) and the app host (signed-in) can render the same // public org page without duplicating the template. -var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_permissions.html", "templates/org_repeaters.html"} +var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_repeaters.html"} //go:embed static/* var staticFS embed.FS diff --git a/internal/web/orgview.go b/internal/web/orgview.go index b260532..6afd3b6 100644 --- a/internal/web/orgview.go +++ b/internal/web/orgview.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "sort" "strconv" "github.com/jleight/meshtender/internal/store" @@ -127,131 +126,3 @@ func PreviewLatLon(r *http.Request) (lat, lon float64, ok bool) { return lat, lon, err1 == nil && err2 == nil } -// CmdCell is one command in a feature-table cell (the "feature-table" partial in -// base.html renders .Template/.Description/.Risky). -type CmdCell struct { - Template string - Description string // shown as a hover tooltip - Risky bool -} - -// FeatureRow is one feature's allowed commands bucketed by operation, for the -// read-only feature×operation tables (consent, requested-access, contribute). -type FeatureRow struct { - Feature string - Read, Write, Delete, Action []CmdCell -} - -// FeatureTableFor groups the commands in `allowed` (the id-set a single tier may -// run) by feature × operation, ordered by featureOrder — one table per tier. This -// is the canonical home for the consent/permission feature table so both the app -// host and the root host (which can't import core) can build it. -func FeatureTableFor(catalog []*store.Command, allowed map[int64]bool) []FeatureRow { - byFeature := map[string]*FeatureRow{} - var present []string - for _, c := range catalog { - if !allowed[c.ID] { - continue - } - row := byFeature[c.Feature] - if row == nil { - row = &FeatureRow{Feature: c.Feature} - byFeature[c.Feature] = row - present = append(present, c.Feature) - } - cell := CmdCell{Template: c.Template, Description: c.Description, Risky: c.Risky} - switch c.Operation { - case "read": - row.Read = append(row.Read, cell) - case "delete": - row.Delete = append(row.Delete, cell) - case "action": - row.Action = append(row.Action, cell) - default: // "write" - row.Write = append(row.Write, cell) - } - } - orderFeatures(present) - out := make([]FeatureRow, 0, len(present)) - for _, f := range present { - out = append(out, *byFeature[f]) - } - return out -} - -// PermissionsView is an org's current requested-access policy for display: one -// feature×operation table per tier. Admins inherit every member command, so the -// admin table is member ∪ admin. -type PermissionsView struct { - Version int - MemberFeatures []FeatureRow - AdminFeatures []FeatureRow - HasRisky bool // any granted command is risky (drives the warning copy) -} - -// BuildPermissionsView loads the org's current permission version and builds the -// member and admin feature tables. -func BuildPermissionsView(ctx context.Context, st *store.Store, orgID int64) (PermissionsView, error) { - versionID, version, err := st.CurrentVersion(ctx, orgID) - if err != nil { - return PermissionsView{}, err - } - adminIDs, memberIDs, err := st.VersionCommandIDs(ctx, versionID) - if err != nil { - return PermissionsView{}, err - } - catalog, err := st.ListCommands(ctx) - if err != nil { - return PermissionsView{}, err - } - member := idSet(memberIDs) - adminUnion := idSet(adminIDs) - for id := range member { - adminUnion[id] = true - } - pv := PermissionsView{ - Version: version, - MemberFeatures: FeatureTableFor(catalog, member), - AdminFeatures: FeatureTableFor(catalog, adminUnion), - } - for _, c := range catalog { - if c.Risky && adminUnion[c.ID] { - pv.HasRisky = true - break - } - } - return pv, nil -} - -func idSet(ids []int64) map[int64]bool { - m := make(map[int64]bool, len(ids)) - for _, id := range ids { - m[id] = true - } - return m -} - -// featureOrder mirrors core's command_features.go display order so the public -// permissions view groups features the same way the in-app review/editor does. -// Unknown features sort after these, alphabetically. -var featureOrder = []string{"Radio", "Routing", "Advertising", "Location", "GPS", "Clock", - "Region", "Neighbors", "Sensors", "Identity", "Access", "Power", "Diagnostics", "Firmware"} - -func featureRank(f string) int { - for i, x := range featureOrder { - if x == f { - return i - } - } - return len(featureOrder) -} - -func orderFeatures(present []string) { - sort.SliceStable(present, func(i, j int) bool { - ri, rj := featureRank(present[i]), featureRank(present[j]) - if ri != rj { - return ri < rj - } - return present[i] < present[j] - }) -} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 6b9d2b0..6d8f1d6 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -187,37 +187,6 @@ {{end}} -{{/* feature-table renders a []featureRow (commands grouped by feature × operation) - for permission review/consent. Dot is the slice of rows. */}} -{{define "feature-table"}} -
- - - - - - {{range .}} - - - {{template "feature-cell" .Read}} - {{template "feature-cell" .Write}} - {{template "feature-cell" .Delete}} - {{template "feature-cell" .Action}} - - {{end}} - -
FeatureReadWriteDeleteAction
{{.Feature}}
-
-{{end}} - -{{/* feature-cell renders one cell of the feature table (a []cmdCell). Hovering a - command shows its description via the title tooltip. */}} -{{define "feature-cell"}} - -{{- if . }}{{range .}}
{{.Template}}{{if .Risky}} risky{{end}}
{{end}}{{else}}{{end -}} - -{{end}} - {{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}} {{define "repstatus"}} {{- if not .Confirmed -}} diff --git a/internal/web/templates/org_permissions.html b/internal/web/templates/org_permissions.html deleted file mode 100644 index b60252d..0000000 --- a/internal/web/templates/org_permissions.html +++ /dev/null @@ -1,43 +0,0 @@ -{{define "title"}}Requested access · {{.Org.Name}} · MeshTender{{end}} -{{define "header"}} -
-
-
{{.Org.Name}}
-

Requested access

-
- {{if .CanEdit}} -
- {{template "icon-pencil" "me-1"}}Edit -
- {{end}} -
-{{end}} -{{define "content"}} -{{template "org-tabs" .Nav}} - -
-
-

- The commands this organization may run on a repeater you contribute, by tier - (current policy v{{.Perms.Version}}). Members consent to this when they - contribute a repeater; you stay on the version you consented to until you re-consent to changes.{{if .Perms.HasRisky}} Risky commands can take over or brick a node.{{end}} -

-
-
- -
-

Members can run

-
- {{if .Perms.MemberFeatures}}{{template "feature-table" .Perms.MemberFeatures}} - {{else}}

Members aren't granted any commands.

{{end}} -
-
- -
-

Admins can run

-
- {{if .Perms.AdminFeatures}}{{template "feature-table" .Perms.AdminFeatures}} - {{else}}

Admins aren't granted any commands.

{{end}} -
-
-{{end}} diff --git a/internal/web/templates/org_repeaters.html b/internal/web/templates/org_repeaters.html index 124e2a5..d9effd4 100644 --- a/internal/web/templates/org_repeaters.html +++ b/internal/web/templates/org_repeaters.html @@ -14,7 +14,7 @@
-

Contributed repeaters

+

Shared repeaters

{{if .Reps.Repeaters}}
{{range .Reps.Repeaters}} @@ -31,7 +31,7 @@
{{else}}

- {{if .Reps.Full}}No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page. + {{if .Reps.Full}}No repeaters are shared yet. A member's repeaters are shared automatically; they can opt one out from its sharing page. {{else}}No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.{{end}}

{{end}} diff --git a/internal/web/templates/org_tabs.html b/internal/web/templates/org_tabs.html index 095b144..1c3645e 100644 --- a/internal/web/templates/org_tabs.html +++ b/internal/web/templates/org_tabs.html @@ -1,5 +1,5 @@ {{/* org-tabs renders the organization sub-navigation. Data: a map with Slug, -Active ("home" | "repeaters" | "members" | "config" | "permissions") and IsMember +Active ("home" | "repeaters" | "members" | "config") and IsMember (the Members tab only shows for members — it exposes personal info). Nil-safe: a missing map renders empty links (harmless for the empty-data compose test). Links are same-host relative so they work on both the app host (signed-in) and the root @@ -10,6 +10,5 @@ host (anonymous). */}} {{if .IsMember}}{{end}} - {{end}}