fix(09): WR-05 refuse relation link and unlink the panel does not declare

This commit is contained in:
Jakub Zych
2026-10-01 21:07:11 +02:00
parent b8084080cb
commit 9bca815b1b
3 changed files with 52 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -456,6 +457,20 @@ func (s *service) relationUnlink(w http.ResponseWriter, r *http.Request) {
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
s.protect(w, r, func(cc *CompiledController) {
// The panel's toolbarButtons are the capability: a relation declared
// without `link` (or `unlink`) refuses that route, whatever the
// controller permission. An unknown relation is the service's 404.
action := "unlink"
if link {
action = "link"
}
if cr, ok := cc.Relations[r.PathValue("name")]; ok && cr != nil && cr.Schema != nil && !slices.Contains(cr.Schema.View.ToolbarButtons, action) {
if principal, _ := bouncer.User(r.Context()); principal != nil {
s.logAuth(r, "denied", principal.ID)
}
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)

View File

@@ -2,6 +2,8 @@ package cabana
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -105,3 +107,37 @@ func TestNavigationDropsDeniedParentAndRepointsTarget(t *testing.T) {
t.Fatalf("navigation = %#v, want the parent to keep its own controller", nav)
}
}
// TestRelationMutationsFollowToolbarButtons pins WR-05: link and unlink are
// refused unless the relation's view panel declares them.
func TestRelationMutationsFollowToolbarButtons(t *testing.T) {
svc := phase09DeniedService()
cc := svc.reg.byID["acme.demo.widgets"]
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
relation := func(buttons ...string) {
cc.Relations = map[string]*CompiledRelation{"editors": {Schema: &RelationSchema{Name: "editors", View: RelationPanel{ToolbarButtons: buttons}}}}
}
call := func(handler func(*service, http.ResponseWriter, *http.Request)) int {
rec := httptest.NewRecorder()
handler(svc, rec, phase09Request(super))
return rec.Code
}
relation("link")
if code := call((*service).relationUnlink); code != http.StatusForbidden {
t.Fatalf("unlink on a link-only relation = %d, want 403", code)
}
if code := call((*service).relationLink); code == http.StatusForbidden {
t.Fatal("link on a link-only relation was refused")
}
relation()
for name, handler := range map[string]func(*service, http.ResponseWriter, *http.Request){"link": (*service).relationLink, "unlink": (*service).relationUnlink} {
if code := call(handler); code != http.StatusForbidden {
t.Fatalf("%s on a relation with no buttons = %d, want 403", name, code)
}
}
relation("link", "unlink")
if code := call((*service).relationUnlink); code == http.StatusForbidden {
t.Fatal("unlink on a link|unlink relation was refused")
}
}