fix(09): WR-05 refuse relation link and unlink the panel does not declare
This commit is contained in:
@@ -49,7 +49,7 @@ editors:
|
||||
|
||||
The controller implements `cabana.AdminRelationContractProvider` and returns a `cabana.RelationContract` per relation: the related and pivot model factories, the pivot's two foreign keys, a map from column names in the YAML to physical columns, and optionally the pivot columns a hook may set and a function that excludes candidate IDs, such as the parent itself. A relation in the YAML without a contract, a contract without a relation, or a relation without a `relation-manager` field stops the start-up.
|
||||
|
||||
`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
|
||||
`cabana.RelationService` serves the panels: linked records, link candidates, link and unlink, under `.../{id}/relations/{name}`. Link and unlink run in a transaction. The `view` panel's `toolbarButtons` decide which of the two the server accepts: a relation that does not list `unlink` answers 403 `forbidden` on the unlink route, and likewise for `link`. `pact.RelationExtendManageQuery` scopes the candidates, and `pact.RelationBeforeLink` can check or fill pivot columns before a link is written.
|
||||
|
||||
## Relations in lists
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user