diff --git a/docs/backend/relation-manager.md b/docs/backend/relation-manager.md index 82918d1..fd4269a 100644 --- a/docs/backend/relation-manager.md +++ b/docs/backend/relation-manager.md @@ -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 diff --git a/modules/cabana/http.go b/modules/cabana/http.go index d5cc0ca..ef36361 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -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) diff --git a/modules/cabana/permissions_test.go b/modules/cabana/permissions_test.go index 8b220d1..709d9cc 100644 --- a/modules/cabana/permissions_test.go +++ b/modules/cabana/permissions_test.go @@ -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") + } +}