Simpler permissions and consent

This commit is contained in:
Jonathon Leight
2026-06-23 19:12:26 -04:00
parent 845d285e90
commit be1a1de0ba
39 changed files with 702 additions and 1107 deletions
+3
View File
@@ -10,3 +10,6 @@ auto = true
[tasks.dev]
run = "go run ./cmd/meshtender"
[tasks.reset]
run = "go run ./cmd/meshtender --reset"
+14
View File
@@ -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
+2 -4
View File
@@ -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.
-200
View File
@@ -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)
}
+165
View File
@@ -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
}
-133
View File
@@ -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)
}
+9 -26
View File
@@ -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"),
})
}
+7 -6
View File
@@ -11,10 +11,11 @@
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
The firmware commands MeshTender can send. A repeater owner can run anything; these flags seed what
others are offered — <strong>share</strong> = default for a new one-off share, <strong>member</strong>/<strong>admin</strong>
= the org default tiers. <strong>Risky</strong> 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. <strong>Share</strong> seeds
the default command set for a new one-off share. <strong>Member</strong>/<strong>admin</strong> are the
ceiling of what an organization may ever run on a contributed repeater, by tier. <strong>Risky</strong>
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.
</p>
{{if .Saved}}<div class="alert alert-success mt-3 mb-0">Saved</div>{{end}}
</div>
@@ -41,8 +42,8 @@
<td class="text-truncate"><code title="{{.Description}}" style="cursor:help">{{.Template}}</code></td>
<td class="text-center"><input class="form-check-input cc-risky m-0" type="checkbox" form="cmd-{{.ID}}" name="risky" aria-label="risky" {{if .Risky}}checked{{end}}></td>
<td class="text-center"><input class="form-check-input cc-share m-0" type="checkbox" form="cmd-{{.ID}}" name="share" aria-label="share" {{if .InShareDefault}}checked{{end}}></td>
<td class="text-center"><input class="form-check-input cc-member m-0" type="checkbox" form="cmd-{{.ID}}" name="org_member" aria-label="member" {{if .InOrgMemberDefault}}checked{{end}}></td>
<td class="text-center"><input class="form-check-input cc-admin m-0" type="checkbox" form="cmd-{{.ID}}" name="org_admin" aria-label="admin" {{if .InOrgAdminDefault}}checked{{end}}></td>
<td class="text-center"><input class="form-check-input cc-member m-0" type="checkbox" form="cmd-{{.ID}}" name="org_member" aria-label="member" {{if .OrgMemberAllowed}}checked{{end}}></td>
<td class="text-center"><input class="form-check-input cc-admin m-0" type="checkbox" form="cmd-{{.ID}}" name="org_admin" aria-label="admin" {{if .OrgAdminAllowed}}checked{{end}}></td>
<td class="text-end"><form id="cmd-{{.ID}}" method="post" action="/admin/catalog/{{.ID}}" class="m-0"><button type="submit" class="btn btn-sm">Save</button></form></td>
</tr>
{{end}}
-43
View File
@@ -1,43 +0,0 @@
{{define "title"}}{{.Repeater.Name}} · {{.Org.Name}} commands · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Consented to {{.Org.Name}} · v{{.Version}}</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
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.
</p>
{{if .CurrentVersion}}
<div class="alert alert-warning mt-3 mb-0">
{{.Org.Name}} has since published v{{.CurrentVersion}}. This repeater stays on v{{.Version}} until you
<a class="alert-link" href="/repeaters/{{.Repeater.PublicID}}/orgs/{{.Org.Slug}}/contribute">review the changes and re-consent</a>.
</div>
{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Members can run</h3></div>
<div class="card-body">
{{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}}
{{else}}<p class="text-secondary mb-0">Members aren't granted any commands.</p>{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Admins can run</h3></div>
<div class="card-body">
{{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}}
{{else}}<p class="text-secondary mb-0">Admins aren't granted any commands.</p>{{end}}
</div>
</div>
<a class="back-link mt-3" href="/repeaters/{{.Repeater.PublicID}}/share">{{template "icon-arrow-left" "me-1"}}Back to sharing</a>
{{end}}
-56
View File
@@ -1,56 +0,0 @@
{{define "title"}}Contribute {{.Repeater.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Contribute to {{.Org.Name}}</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
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.
</p>
{{if .Reconsent}}
<div class="alert alert-warning mt-3 mb-0">
<h4 class="alert-title">Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}})</h4>
{{if .Added}}<div>Newly granted: {{range .Added}}<code>{{.}}</code> {{end}}</div>{{end}}
{{if .Removed}}<div>No longer granted: {{range .Removed}}<code>{{.}}</code> {{end}}</div>{{end}}
{{if not (or .Added .Removed)}}<div>No command changes (notes/metadata only).</div>{{end}}
{{if .Notes}}<div class="text-secondary mt-2">
{{range .Notes}}v{{.Version}}{{if .Note}}: {{.Note}}{{end}}<br>{{end}}
</div>{{end}}
</div>
{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Members can run</h3></div>
<div class="card-body">
{{if .MemberFeatures}}{{template "feature-table" .MemberFeatures}}
{{else}}<p class="text-secondary mb-0">Members aren't granted any commands.</p>{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Admins can run</h3></div>
<div class="card-body">
{{if .AdminFeatures}}{{template "feature-table" .AdminFeatures}}
{{else}}<p class="text-secondary mb-0">Admins aren't granted any commands.</p>{{end}}
</div>
</div>
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/orgs/{{.Org.Slug}}/contribute">
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">I consent — contribute this repeater</button>
<a class="btn" href="/repeaters/{{.Repeater.PublicID}}/share">Cancel</a>
</div>
</form>
{{end}}
-7
View File
@@ -9,13 +9,6 @@
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
{{if .Reconsent}}
<div class="alert alert-warning" role="alert">
<div class="d-flex"><div>{{template "icon-alert" "alert-icon"}}</div>
<div>One or more of your repeaters need re-consent after an organization changed its command policy.
<a href="/repeaters" class="alert-link">Review on the Repeaters page</a>.</div></div>
</div>
{{end}}
<!-- Summary stats -->
<div class="row row-cards">
+52
View File
@@ -0,0 +1,52 @@
{{define "title"}}Limit commands · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Limit commands</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
Restrict which of the commands <strong>{{.Org.Name}}</strong> is permitted to run actually run on
<em>your</em> 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}}
</p>
</div>
</div>
<form method="post" action="/orgs/{{.Org.Slug}}/my-commands" id="cmdform-perms">
{{range .Groups}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
<div class="card-body">
{{range .Commands}}
<label class="form-check">
<input class="form-check-input" type="checkbox" name="cmd" value="{{.ID}}" {{if .Checked}}checked{{end}}>
<span class="form-check-label">
<code>{{.Template}}</code>
{{if .Risky}}<span class="badge bg-warning-lt">risky</span>{{end}}
{{if .Args}}<span class="text-secondary">{{.Args}}</span>{{end}}
</span>
</label>
{{end}}
</div>
</div>
{{end}}
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Save restriction</button>
{{if .Restricted}}
<button type="submit" name="clear" value="1" class="btn">Remove restriction (allow all)</button>
{{end}}
<a class="btn" href="/orgs/{{.Org.Slug}}">Cancel</a>
</div>
</form>
{{end}}
@@ -1,55 +0,0 @@
{{define "title"}}Edit requested access · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Edit requested access</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">Back to requested access</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
Current version: <strong>v{{.Version}}</strong>. Choose which commands org <strong>admins</strong>
and <strong>members</strong> 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.
</p>
</div>
</div>
<form method="post" action="/orgs/{{.Org.Slug}}/permissions/edit">
{{range .Groups}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
<div class="card-body">
{{range .Commands}}
<div class="d-flex align-items-center flex-wrap gap-3 py-1">
<code class="flex-fill">{{.Template}}{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</code>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="admin" value="{{.ID}}" {{if .AdminChecked}}checked{{end}}><span class="form-check-label">admin</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="member" value="{{.ID}}" {{if .MemberChecked}}checked{{end}}><span class="form-check-label">member</span></label>
</div>
{{end}}
</div>
</div>
{{end}}
<div class="card mt-3">
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="change_note">Change note <span class="text-secondary">(shown to owners when they re-consent)</span></label>
<input type="text" class="form-control" id="change_note" name="note" maxlength="200" placeholder="e.g. added set tx for the new region plan">
</div>
<div class="btn-list">
<button type="submit" class="btn btn-primary">Publish new version</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions">Cancel</a>
</div>
</div>
</div>
</form>
{{end}}
+2 -3
View File
@@ -93,17 +93,16 @@
<span class="text-secondary">{{template "icon-world" ""}}</span>
<div class="flex-fill text-truncate">
<a class="fw-bold text-reset" href="/orgs/{{.OrgSlug}}">{{.OrgName}}</a>
<div class="text-secondary small">consented to v{{.ConsentedVersion}}{{if gt .CurrentVersion .ConsentedVersion}} <span class="badge bg-warning-lt">re-consent</span>{{end}}</div>
</div>
<div class="btn-list flex-nowrap">
<a class="btn btn-sm" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/consented" title="What v{{.ConsentedVersion}} permits org members to run on this repeater">{{template "icon-list" "me-1"}}Permissions</a>
<a class="btn btn-sm" href="/orgs/{{.OrgSlug}}/my-commands" title="Limit which commands this org may run on your repeaters">{{template "icon-list" "me-1"}}Commands</a>
<a class="btn btn-sm" href="/orgs/{{.OrgSlug}}/config{{if $.Repeater.Latitude}}?lat={{$.Repeater.Latitude}}&lon={{$.Repeater.Longitude}}{{end}}" title="This org's recommended config for this repeater">{{template "icon-settings" "me-1"}}Config</a>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">Not contributed to any organization.</p></div>
<div class="card-body"><p class="text-secondary mb-0">Not shared with any organization.</p></div>
{{end}}
</div>
+8 -5
View File
@@ -43,19 +43,22 @@
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Step 4 · Contribute to an organization <span class="text-secondary">(optional)</span></h3>
<h3 class="card-title">Step 4 · Organizations <span class="text-secondary">(optional)</span></h3>
{{if .Orgs}}
<p class="text-secondary">
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.
</p>
<div class="list-group list-group-flush">
{{range .Orgs}}
<div class="list-group-item d-flex align-items-center gap-2 px-0">
<span class="fw-bold">{{.Org.Name}}</span>
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.Org.Slug}}/contribute">Review &amp; contribute</a>
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.Org.Slug}}/participation" class="m-0 ms-auto">
<input type="hidden" name="action" value="exclude">
<button type="submit" class="btn btn-sm btn-ghost-danger">Opt out</button>
</form>
</div>
{{end}}
</div>
-1
View File
@@ -25,7 +25,6 @@
<div class="d-flex align-items-center flex-wrap gap-2">
<h3 class="m-0 fs-3"><a class="text-reset text-decoration-none" href="/repeaters/{{.PublicID}}">{{.Name}}</a></h3>
{{template "repstatus" .}}
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.PublicID}}/share">re-consent needed</a>{{end}}
</div>
<div class="d-flex align-items-center flex-wrap gap-2 mt-1 text-secondary">
<span class="font-monospace">{{slice .PublicKeyHex 0 8}}…</span>
+19 -27
View File
@@ -23,44 +23,36 @@
<div class="card-body">
<h3 class="card-title">Organizations</h3>
<p class="text-secondary">
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 <strong>Limit commands</strong> page.
</p>
<div class="subheader mt-3 mb-2">Contributed to</div>
{{if .Contributed}}
{{if .Orgs}}
<div class="list-group list-group-flush">
{{range .Contributed}}
{{range .Orgs}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2 px-0">
<span class="fw-bold">{{.OrgName}}</span>
<span class="text-secondary"><a class="link-secondary" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/consented">consented to v{{.ConsentedVersion}}</a>{{if .NeedsReconsent}} · org is now on v{{.CurrentVersion}}{{end}}</span>
{{if .NeedsReconsent}}
<a class="btn btn-sm" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/contribute">Review changes</a>
{{if .Excluded}}
<span class="badge bg-secondary-lt">Opted out</span>
{{else}}
<span class="badge bg-success-lt">Shared</span>
<a class="text-secondary small" href="/orgs/{{.OrgSlug}}/my-commands">Limit commands</a>
{{end}}
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/withdraw" class="m-0 ms-auto" onsubmit="return confirm('Withdraw this repeater from {{.OrgName}}?')">
<button type="submit" class="btn btn-sm btn-ghost-danger">Withdraw</button>
<form method="post" action="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.OrgSlug}}/participation" class="m-0 ms-auto">
{{if .Excluded}}
<input type="hidden" name="action" value="include">
<button type="submit" class="btn btn-sm">Share with {{.OrgName}}</button>
{{else}}
<input type="hidden" name="action" value="exclude">
<button type="submit" class="btn btn-sm btn-ghost-danger" onclick="return confirm('Opt this repeater out of {{.OrgName}}?')">Opt out</button>
{{end}}
</form>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">Not contributed to any organization.</p>
{{end}}
{{if .Available}}
<div class="subheader mt-3 mb-2">Contribute to</div>
<div class="list-group list-group-flush">
{{range .Available}}
<div class="list-group-item d-flex align-items-center gap-2 px-0">
<span class="fw-bold">{{.Name}}</span>
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.PublicID}}/orgs/{{.Slug}}/contribute">Review &amp; contribute</a>
</div>
{{end}}
</div>
{{else if .Contributed}}
{{else}}
<p class="text-secondary">Join an organization to contribute this repeater to it.</p>
<p class="text-secondary">Join an organization to share this repeater with it.</p>
{{end}}
</div>
</div>
+3 -19
View File
@@ -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"),
})
+1 -2
View File
@@ -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
}
-26
View File
@@ -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) {
+1 -1
View File
@@ -46,7 +46,7 @@
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-share" ""}}</span>
<h3 class="card-title m-0">Share with precision</h3>
</div>
<p class="text-secondary mb-0">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.</p>
<p class="text-secondary mb-0">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.</p>
</div>
</div>
</div>
+13 -9
View File
@@ -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)
@@ -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);
+43
View File
@@ -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
})
}
-116
View File
@@ -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
}
+75 -90
View File
@@ -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
})
}
+10 -21
View File
@@ -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)
+34 -25
View File
@@ -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 {
+6 -2
View File
@@ -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 {
+59
View File
@@ -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
}
+74
View File
@@ -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)
}
}
+19 -18
View File
@@ -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 {
+6 -3
View File
@@ -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)
+2 -2
View File
@@ -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
-129
View File
@@ -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]
})
}
-31
View File
@@ -187,37 +187,6 @@
</details>
{{end}}
{{/* feature-table renders a []featureRow (commands grouped by feature × operation)
for permission review/consent. Dot is the slice of rows. */}}
{{define "feature-table"}}
<div class="table-responsive">
<table class="table table-vcenter">
<thead><tr>
<th>Feature</th><th>Read</th><th>Write</th><th>Delete</th><th>Action</th>
</tr></thead>
<tbody>
{{range .}}
<tr>
<td class="fw-bold align-top">{{.Feature}}</td>
{{template "feature-cell" .Read}}
{{template "feature-cell" .Write}}
{{template "feature-cell" .Delete}}
{{template "feature-cell" .Action}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{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"}}
<td class="align-top">
{{- if . }}{{range .}}<div class="mb-1"><code title="{{.Description}}" style="cursor:help">{{.Template}}</code>{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</div>{{end}}{{else}}<span class="text-secondary"></span>{{end -}}
</td>
{{end}}
{{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}}
{{define "repstatus"}}
{{- if not .Confirmed -}}
@@ -1,43 +0,0 @@
{{define "title"}}Requested access · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Requested access</h2>
</div>
{{if .CanEdit}}
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/permissions/edit">{{template "icon-pencil" "me-1"}}Edit</a>
</div>
{{end}}
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
The commands this organization may run on a repeater you contribute, by tier
(current policy <strong>v{{.Perms.Version}}</strong>). 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}}
</p>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Members can run</h3></div>
<div class="card-body">
{{if .Perms.MemberFeatures}}{{template "feature-table" .Perms.MemberFeatures}}
{{else}}<p class="text-secondary mb-0">Members aren't granted any commands.</p>{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Admins can run</h3></div>
<div class="card-body">
{{if .Perms.AdminFeatures}}{{template "feature-table" .Perms.AdminFeatures}}
{{else}}<p class="text-secondary mb-0">Admins aren't granted any commands.</p>{{end}}
</div>
</div>
{{end}}
+2 -2
View File
@@ -14,7 +14,7 @@
<!-- Left: list -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Contributed repeaters</h3></div>
<div class="card-header"><h3 class="card-title">Shared repeaters</h3></div>
{{if .Reps.Repeaters}}
<div class="list-group list-group-flush">
{{range .Reps.Repeaters}}
@@ -31,7 +31,7 @@
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">
{{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}}
</p></div>
{{end}}
+1 -2
View File
@@ -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}}<li class="nav-item"><a class="nav-link{{if eq .Active "members"}} active{{end}}" href="/orgs/{{.Slug}}/members">{{template "icon-users" "me-1"}}Members</a></li>{{end}}
<li class="nav-item"><a class="nav-link{{if eq .Active "repeaters"}} active{{end}}" href="/orgs/{{.Slug}}/repeaters">{{template "icon-antenna" "me-1"}}Repeaters</a></li>
<li class="nav-item"><a class="nav-link{{if eq .Active "config"}} active{{end}}" href="/orgs/{{.Slug}}/config">{{template "icon-settings" "me-1"}}Configuration</a></li>
<li class="nav-item"><a class="nav-link{{if eq .Active "permissions"}} active{{end}}" href="/orgs/{{.Slug}}/permissions">{{template "icon-list" "me-1"}}Requested access</a></li>
</ul>
{{end}}